Create your own
Lesson illustration

Register Attach, Detach, and Session Status Commands

The Frida host can now attach to a process, load an agent, and close its resources safely. The next step is to make those capabilities part of the tool’s public interface without allowing terminal concerns to leak into the application or Frida layers.

This lesson connects the session lifecycle from the previous lesson to the command registry built in Module 3. By the end, both one-shot CLI input and interactive REPL input will be able to run attach, detach, and session-status through the same typed use cases. The Frida adapter remains an implementation detail, and the composition root—not a global service locator—will assemble the pieces.


Keep the command surface separate from the application surface

A command is a user-interface adapter. It understands words such as attach, positional arguments such as 1234, and terminal-oriented concerns such as usage text. A use case is an application operation. It receives typed input and returns typed outcomes.

That distinction is what lets these inputs mean exactly the same thing:

grasp attach 1234
grasp> attach 1234

Neither the CLI parser nor the REPL parser should call frida.attach() directly. Both should parse text into your shared ParsedCommand model, locate a command definition in the registry, and invoke its handler. The handler validates command-specific input and delegates to an application use case.

The oclif documentation makes the same architectural point from the perspective of a framework: commands are a user interface, not a reusable code interface.

Running Commands Programmatically | oclif: The Open CLI Framework

Read the opening of oclif’s “Running Commands Programmatically.” Although this project does not need to adopt oclif, its warning against invoking one command from another supports the boundary we need: share application modules, not command classes.

In the “Sharing code with modules” section, read from the architectural warning. Then follow the config list and config update example through the paragraph beginning the recommended approach. Focus on why rendering and command orchestration should be extracted rather than reused by calling a command implementation.

For this tool, the layers should have these responsibilities:

LayerKnows aboutMust not know about
CLI / REPL adaptercommand text, arguments, help, completion, terminal renderingfrida.Session, frida.Script, agent source
Application use casestyped PIDs, active-session policy, typed application errorsprocess.argv, readline, terminal colors
Frida infrastructure adapterFrida attachment, agent loading, lifecycle callbacksCLI command names and terminal output
Composition rootconcrete implementations and dependency wiringbusiness decisions beyond assembly

The practical test is simple: an integration test should be able to call attachSession.execute({ pid }) without constructing a terminal parser. Conversely, a command-registry test should run an attach command using a fake use case, with no Frida installation or target process.


Define a stable application-facing session contract

The lifecycle owner from the previous lesson holds Frida-specific objects and states. It is correct for the Frida adapter to use it internally, but the application should not depend on its concrete FridaLifecycleState type.

Instead, expose a narrow port and an application-oriented snapshot. Reuse the branded ProcessId and SessionId types established in Module 1.

export type SessionPhase =
  | "loading"
  | "ready"
  | "closing"
  | "closed";

export type SessionEndCause =
  | "requested"
  | "target-detached"
  | "script-destroyed"
  | "startup-failed";

export type SessionSnapshot = Readonly<{
  readonly sessionId: SessionId;
  readonly targetPid: ProcessId;
  readonly phase: SessionPhase;
  readonly endCause?: SessionEndCause;
  readonly warningCount: number;
}>;

export interface ManagedInstrumentationSession {
  snapshot(): SessionSnapshot;

  close(
    cause: { readonly kind: "requested" },
  ): Promise<SessionSnapshot>;
}

export interface InstrumentationSessionConnector {
  attach(
    input: Readonly<{
      readonly targetPid: ProcessId;
      readonly signal: AbortSignal;
    }>,
  ): Promise<Result<ManagedInstrumentationSession, ApplicationError>>;
}

There are several intentional choices here:

  • SessionSnapshot is data, so the CLI and REPL can render it without holding a mutable Frida object.
  • ManagedInstrumentationSession is deliberately small. The current feature needs a snapshot and controlled shutdown; future probe and recording capabilities can be introduced through separate ports.
  • The session wrapper exposes close() rather than session.detach(). This preserves the idempotent cleanup policy: unload the agent where possible, stop message ingress, then release the attachment.
  • The application receives a cancellation signal, but the Frida adapter owns the reality that an in-progress native attach may not be immediately interruptible.

Your concrete FridaSessionConnector can internally construct the AttachedAgentLifecycle from the preceding lesson. It should map the lifecycle’s detailed state into the stable SessionSnapshot above.

For example, a terminal Frida state such as:

{
  kind: "closed",
  cause: {
    kind: "target-detached",
    reason: "process-terminated",
  },
  warnings: [],
}

can become:

{
  sessionId,
  targetPid,
  phase: "closed",
  endCause: "target-detached",
  warningCount: 0,
}

The application does not need Frida’s raw detach reason to decide whether a session is usable. Diagnostics can retain the richer detail within the infrastructure layer and later recording slice.


Use a small coordinator for the active session

For the first usable version, the process is allowed one active instrumentation session at a time. This is a product policy, not an incidental limitation of the CLI. Put that policy in an application service.

Call it SessionWorkspace, ActiveSessionCoordinator, or a similarly explicit name. Do not call it a generic SessionManager: that name tends to become a dumping ground.

export type SessionStatus =
  | Readonly<{
      readonly kind: "no-session";
      readonly lastClosed?: SessionSnapshot;
    }>
  | Readonly<{
      readonly kind: "session";
      readonly snapshot: SessionSnapshot;
    }>;

export class SessionWorkspace {
  readonly #connector: InstrumentationSessionConnector;

  #current: ManagedInstrumentationSession | undefined;
  #lastClosed: SessionSnapshot | undefined;

  constructor(connector: InstrumentationSessionConnector) {
    this.#connector = connector;
  }

  status(): SessionStatus {
    if (this.#current === undefined) {
      return {
        kind: "no-session",
        lastClosed: this.#lastClosed,
      };
    }

    const snapshot = this.#current.snapshot();

    if (snapshot.phase === "closed") {
      this.#lastClosed = snapshot;
      this.#current = undefined;

      return {
        kind: "no-session",
        lastClosed: snapshot,
      };
    }

    return {
      kind: "session",
      snapshot,
    };
  }

  async attach(
    input: Readonly<{
      readonly targetPid: ProcessId;
      readonly signal: AbortSignal;
    }>,
  ): Promise<Result<SessionStatus, ApplicationError>> {
    const currentStatus = this.status();

    if (currentStatus.kind === "session") {
      return err({
        kind: "session-already-active",
        sessionId: currentStatus.snapshot.sessionId,
        targetPid: currentStatus.snapshot.targetPid,
      });
    }

    if (input.signal.aborted) {
      return err({
        kind: "operation-cancelled",
        operation: "attach",
      });
    }

    const attached = await this.#connector.attach(input);

    if (!attached.ok) {
      return attached;
    }

    /*
     * Frida attachment may finish after the user cancels. Do not leave a
     * newly attached session behind when the caller no longer wants it.
     */
    if (input.signal.aborted) {
      const closed = await attached.value.close({
        kind: "requested",
      });

      this.#lastClosed = closed;

      return err({
        kind: "operation-cancelled",
        operation: "attach",
      });
    }

    this.#current = attached.value;

    return ok(this.status());
  }

  async detach(): Promise<Result<SessionStatus, ApplicationError>> {
    const currentStatus = this.status();

    if (currentStatus.kind === "no-session") {
      return ok(currentStatus);
    }

    const closed = await this.#current!.close({
      kind: "requested",
    });

    this.#lastClosed = closed;
    this.#current = undefined;

    return ok(this.status());
  }
}

The Result, ok(), and err() helpers above stand for the typed-result convention established in Module 1. Keep using your existing names if they differ.

Why make detach idempotent at the use-case level?

The Frida lifecycle itself is already idempotent: repeated close() calls share one cleanup operation. The application-level detach() should also behave usefully when no session exists.

A user may type detach twice, press Ctrl+C during a long interaction, or encounter a target that has already exited. Reporting “nothing is currently attached” as a normal status is more useful than making it an exceptional terminal failure.

The distinction is:

  • Invalid attach while a live session exists is an application error, because it would silently violate the one-active-session policy.
  • Detach with no active session is a successful no-op, because the desired end state already holds.

Why retain the last closed snapshot?

If the target exits unexpectedly, session-status should be able to tell the user what happened:

No active session.
Last session: PID 1234, closed because target detached.

Without a final snapshot, the target’s exit can look indistinguishable from “the tool was never attached.” This is especially unhelpful when investigating short-lived Windows processes.

This is still deliberately modest state. Module 2’s domain state machine will later provide richer business semantics around probes and recordings. Here, the coordinator is only preserving enough application state to control a live Frida attachment coherently.


Make each operation an explicit use case

The workspace is stateful infrastructure for the application layer. The public operations should remain small, named use cases. This makes them easy to call from commands, tests, and later automation APIs.

export class AttachSession {
  readonly #workspace: SessionWorkspace;

  constructor(workspace: SessionWorkspace) {
    this.#workspace = workspace;
  }

  execute(
    input: Readonly<{
      readonly targetPid: ProcessId;
      readonly signal: AbortSignal;
    }>,
  ): Promise<Result<SessionStatus, ApplicationError>> {
    return this.#workspace.attach(input);
  }
}

export class DetachSession {
  readonly #workspace: SessionWorkspace;

  constructor(workspace: SessionWorkspace) {
    this.#workspace = workspace;
  }

  execute(): Promise<Result<SessionStatus, ApplicationError>> {
    return this.#workspace.detach();
  }
}

export class GetSessionStatus {
  readonly #workspace: SessionWorkspace;

  constructor(workspace: SessionWorkspace) {
    this.#workspace = workspace;
  }

  execute(): Result<SessionStatus, never> {
    return ok(this.#workspace.status());
  }
}

The classes are not mandatory. Functions can work equally well. What matters is that each operation has:

  1. a precise input type;
  2. a typed success result;
  3. an expected application-error vocabulary;
  4. no terminal parsing or terminal rendering.

Avoid making GetSessionStatus.execute() asynchronous merely because attach() and detach() are asynchronous. Status is a local snapshot, so it should return immediately. Honest APIs make concurrency easier to reason about.

At this stage, three application errors are especially valuable:

export type ApplicationError =
  | Readonly<{
      readonly kind: "invalid-process-id";
      readonly input: string;
    }>
  | Readonly<{
      readonly kind: "session-already-active";
      readonly sessionId: SessionId;
      readonly targetPid: ProcessId;
    }>
  | Readonly<{
      readonly kind: "attach-failed";
      readonly targetPid: ProcessId;
      readonly detail: string;
    }>
  | Readonly<{
      readonly kind: "operation-cancelled";
      readonly operation: "attach";
    }>;

The Frida connector translates expected Frida failures, such as an inaccessible PID or failed agent startup, into attach-failed. It must not expose arbitrary thrown values to terminal code.


Adapt the use cases into registry commands

Module 3 established the important direction of dependency:

parsed input → command definition → application use case → typed response → renderer

Keep this as a conceptual boundary, not as a chain of direct imports from one feature into another. The command factory below assumes your existing registry uses a RegisteredCommand shape with a command name, help text, and an asynchronous execute() function.

export type SessionCommandDependencies = Readonly<{
  readonly attachSession: AttachSession;
  readonly detachSession: DetachSession;
  readonly getSessionStatus: GetSessionStatus;
}>;

export function createSessionCommands(
  dependencies: SessionCommandDependencies,
): readonly RegisteredCommand[] {
  return [
    {
      name: "attach",
      summary: "Attach to a local process by PID.",
      usage: "attach <pid>",
      completionCandidates: [],
      async execute(input, context) {
        const pidText = requireSingleArgument(input, "attach <pid>");

        if (!pidText.ok) {
          return pidText;
        }

        const processId = parseProcessId(pidText.value);

        if (!processId.ok) {
          return processId;
        }

        const result = await dependencies.attachSession.execute({
          targetPid: processId.value,
          signal: context.signal,
        });

        return mapResult(result, sessionStatusResponse);
      },
    },
    {
      name: "detach",
      summary: "Detach from the current instrumentation session.",
      usage: "detach",
      completionCandidates: [],
      async execute(input) {
        const noArguments = requireNoArguments(input, "detach");

        if (!noArguments.ok) {
          return noArguments;
        }

        const result = await dependencies.detachSession.execute();

        return mapResult(result, sessionStatusResponse);
      },
    },
    {
      name: "session-status",
      summary: "Show the active session or the most recently closed session.",
      usage: "session-status",
      completionCandidates: [],
      execute(input) {
        const noArguments = requireNoArguments(
          input,
          "session-status",
        );

        if (!noArguments.ok) {
          return noArguments;
        }

        const result = dependencies.getSessionStatus.execute();

        return mapResult(result, sessionStatusResponse);
      },
    },
  ];
}

The utility functions are intentionally command-boundary concerns:

  • requireSingleArgument() checks command arity and produces a usage error.
  • parseProcessId() validates the text as a positive integer within the PID range and brands it as ProcessId.
  • requireNoArguments() rejects surprising input such as detach 1234.
  • mapResult() preserves typed success or failure while converting successful application data into a display-neutral command response.

A possible display-neutral response type is:

export type CommandResponse =
  | Readonly<{
      readonly kind: "session-status";
      readonly status: SessionStatus;
    }>
  | Readonly<{
      readonly kind: "usage-error";
      readonly usage: string;
      readonly detail: string;
    }>
  | Readonly<{
      readonly kind: "application-error";
      readonly error: ApplicationError;
    }>;

function sessionStatusResponse(
  status: SessionStatus,
): CommandResponse {
  return {
    kind: "session-status",
    status,
  };
}

Notice what the command definitions do not do:

  • They do not import frida.
  • They do not call console.log.
  • They do not format a table.
  • They do not decide whether an error should terminate the REPL.
  • They do not create a SessionWorkspace.

They adapt one public command name into one use case. That makes this factory a clean vertical-slice entry point rather than a second application layer.


Render one response consistently in CLI and REPL modes

Your existing terminal renderer should be the only place that turns CommandResponse into text. It can render the same status differently depending on terminal capabilities later, but command behavior remains identical.

export function renderCommandResponse(
  response: CommandResponse,
): readonly string[] {
  switch (response.kind) {
    case "session-status":
      return renderSessionStatus(response.status);

    case "usage-error":
      return [
        `Usage: ${response.usage}`,
        response.detail,
      ];

    case "application-error":
      return [renderApplicationError(response.error)];
  }
}

function renderSessionStatus(
  status: SessionStatus,
): readonly string[] {
  if (status.kind === "no-session") {
    if (status.lastClosed === undefined) {
      return ["No active instrumentation session."];
    }

    return [
      "No active instrumentation session.",
      `Last session: PID ${status.lastClosed.targetPid}, ` +
        `closed because ${status.lastClosed.endCause ?? "unknown"}.`,
    ];
  }

  const { snapshot } = status;

  return [
    `Session ${snapshot.sessionId}`,
    `Target PID: ${snapshot.targetPid}`,
    `State: ${snapshot.phase}`,
    `Lifecycle warnings: ${snapshot.warningCount}`,
  ];
}

The renderer should treat a closing session honestly. For example:

Session session-42
Target PID: 1234
State: closing
Lifecycle warnings: 0

Do not claim that the session is detached until the lifecycle has reached closed. This directly follows the distinction made in the preceding lesson between initiating cleanup and observing completed cleanup.

The REPL loop should render recoverable errors and continue prompting. A failed attach 999999 must not close the REPL; it should display the typed attach-failed response and return control to the user.


Register the slice in the composition root

The composition root is the one place where concrete infrastructure meets application code. A minimal assembly might look like this:

const connector = new FridaSessionConnector({
  frida,
  agentBundle,
  logger,
});

const workspace = new SessionWorkspace(connector);

const attachSession = new AttachSession(workspace);
const detachSession = new DetachSession(workspace);
const getSessionStatus = new GetSessionStatus(workspace);

const sessionCommands = createSessionCommands({
  attachSession,
  detachSession,
  getSessionStatus,
});

const commandRegistry = createCommandRegistry([
  ...sessionCommands,
  ...createCoreCommands(),
]);

const renderer = new TerminalCommandRenderer();

await runCliOrRepl({
  commandRegistry,
  renderer,
});

SessionWorkspace is stateful, and it is intentionally shared by the three session use cases. That is not a service locator: dependencies are explicit constructor or factory arguments, and only the composition root selects their concrete implementations.

A global service locator would look more like this:

const workspace = container.get<SessionWorkspace>("workspace");

inside a command handler. Avoid that pattern. It hides dependencies, makes tests harder to assemble, and encourages unrelated features to reach into mutable session state.

One-shot and interactive paths should converge

Your mode-selection code should choose only the input source, not a different implementation of session control:

if (argv.length > 0) {
  await runOneShotCommand({
    argv,
    registry: commandRegistry,
    renderer,
  });
} else {
  await runInteractiveRepl({
    registry: commandRegistry,
    renderer,
  });
}

Both paths should eventually invoke the same registry dispatch operation. The one-shot CLI may map failures to a nonzero exit code after rendering; the REPL instead keeps running after a recoverable command failure. That is a mode-level policy, not a reason to duplicate attach logic.

If you ever replace the custom readline/promises REPL with Node’s built-in repl module, Node does support dot-prefixed custom commands through replServer.defineCommand(). However, do not let that API dictate this architecture: your registry should remain the command authority, and any Node REPL command should be only another adapter around it.


Verify the boundaries with focused tests

You do not need a real Frida target to test command registration and use-case policy. Split the tests by responsibility.

Application-use-case tests

Use a fake InstrumentationSessionConnector and fake managed session.

ScenarioExpected behavior
Attach with no active sessionConnector receives the branded PID; returned status contains the new session.
Attach while ready session existsConnector is not called; result is session-already-active.
Detach after attachFake session’s close() is called once with requested; status becomes no-session.
Detach with no sessionSuccessful no-session status; no exception.
Target becomes closed externallystatus() retires the current session and exposes it as lastClosed.
Cancellation observed after attach completesNew session is closed rather than left attached; result is operation-cancelled.

Registry-adapter tests

Inject fake use cases and invoke the registry through the same dispatcher used by CLI and REPL.

InputExpected delegation or response
attach 1234Calls AttachSession with a branded PID and the command cancellation signal.
attachReturns a usage-error; no use case is called.
attach helloReturns invalid-process-id; no Frida adapter is involved.
detachCalls DetachSession; returns a session-status response.
session-statusCalls GetSessionStatus; renderer receives a typed status response.

Keep one small integration check for the real Windows fixture: invoke the public attach command through the registry, check session-status, invoke detach, then check the final status. This tests the public route without requiring the terminal itself to be interactive.


Key takeaways

The session lifecycle is now ready to become a public capability without exposing Frida details across the application:

  • Treat attach, detach, and session-status as explicit application use cases.
  • Let a small application coordinator enforce the one-active-session policy and retain the latest terminal snapshot.
  • Expose a narrow ManagedInstrumentationSession contract instead of raw Frida objects.
  • Build registry commands as adapters that validate command input, invoke use cases, and return typed display-neutral responses.
  • Use one registry for both one-shot CLI commands and REPL commands.
  • Wire concrete Frida infrastructure only in the composition root.
  • Preserve idempotent lifecycle cleanup by ensuring all detachment flows pass through the managed session’s close() method.

The next module turns process discovery into the first complete vertical slice: a Frida-backed process provider, deterministic filtering and sorting, and shared list-processes and target-selection commands.

Can't find a good explanation? Sign up and we'll make it for you

Sign up