Skip to content

refactor(daemon): inject the command surface as a port - #3402

Merged
thymikee merged 4 commits into
refactor/command-runtime-contractsfrom
refactor/daemon-command-surface-injection
Oct 11, 2026
Merged

thymikee merged 4 commits into
refactor/command-runtime-contractsfrom
refactor/daemon-command-surface-injection

Conversation

@thymikee

@thymikee thymikee commented Oct 10, 2026 •

Copy link
Copy Markdown
Member

Summary

Stacked on #3394. The daemon stops importing createCommandSurfaceAgentDevice: the composition root (src/daemon.ts) supplies it as the required createCommandSurface port, typed by CreateDaemonCommandSurface in @agent-device/contracts/daemon-command-surface. The port is an async factory kept behind await import in the executable, so the command surface loads on first request use — the daemon entry's eager closure stays at the merge-base's 470 modules. Threading path: RequestRouterDeps -> request-handler-chain params -> session/snapshot/find/interaction handlers -> generic dispatch -> execution runtimes (interaction, screenshot, selector, snapshot, sparse-fallback, generic-settle, session-open), whose four construction sites await the port. Behavior unchanged; the field stays required, so 146 test param-bag literals gained one line via a shared fixture (src/daemon/__tests__/command-surface-fixture.ts). No assertions changed except three exact-forward-shape tests that now include the port.

Diff budget: the production change is ~490 lines (29 files); the rest is the mechanical AST-codemod insertion of the required createCommandSurface fixture line into 146 test param bags, and splitting it out would leave an intermediate commit that does not type-check.

Validation

Commit 7c4f6b716 (stack rebased onto current main via #3394, then replayed). pnpm build, pnpm check:quick, pnpm check:layering, pnpm check:di-seams, test-file size ratchet, and pnpm check:affected --run pass on this head. Depgraph query: zero edges from daemon/** production nodes to command-runtime/** or commands/** nodes (jq on .tmp/depgraph/graph.json); the only edge into command-runtime/runtime-command-surface.ts originates from the daemon.ts executable (the dynamic-import seam).

@thymikee
thymikee force-pushed the refactor/daemon-command-surface-injection branch from 06b89da to f21198a Compare October 10, 2026 21:55
@github-actions

github-actions Bot commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Size Report

Metric Base Current Diff
Installed (including dependencies) 5.16 MB 5.16 MB +2.1 kB
Package (unpacked) 5.16 MB 5.16 MB +2.1 kB
Package (download) 1.55 MB 1.55 MB +821 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 20.4 ms 20.7 ms +0.3 ms
CLI --help 51.5 ms 53.2 ms +1.7 ms

@thymikee

Copy link
Copy Markdown
Member Author

I found nothing that needs a fix in f21198a, but this review is not a full check. I did not run tsc, depgraph or check:di-seams, so the zero-edge depgraph claim rests on my read of the PR head, not on the graph. The injected value is the same createCommandSurfaceAgentDevice function the old imports supplied, called with the same config, so I did not ask for a live device run. I did not read all 143 test bag edits one by one. I filtered the diff for non-fixture lines and found only a reformat.

The 14 checks are green and none fail. I know of no conflicts. The base is the stacked branch refactor/command-runtime-contracts (#3394), which is still open, and this review assumes #3394 lands as reviewed. This PR can merge after #3394 merges and it is rebased onto main.

Not blocking: the port in daemon-command-surface.ts re-declares option shapes that src/commands already owns, such as DaemonWaitTarget, DaemonSelectorSnapshotOptions and the gesture and press/fill/longPress options. Each shape now has two declarations, and only the assignability check in daemon.ts ties them together. The rule would be one declaration per command option or result shape, in contracts, with src/commands importing it. Also, the members of DaemonCommandSurfaceCapture, DaemonCommandSurfaceSelectors and DaemonCommandSurfaceInteractions use method shorthand, which TypeScript checks bivariantly (see line 300). A required field added to the implementation's options would still compile, which weakens the header claim at lines 1-4. Function-typed properties would make strictFunctionTypes check them. Take or leave both.

Is the 357-line contracts mirror the part to cut? The threading itself matches how RequestRouterDeps already carries androidObservation and platformResourceCleanup. I found no single per-request bag that reaches all four construction sites, so I accept it at 433 net production lines. If the command option and result types the daemon calls (wait, find/get/is, press/fill/longPress/hover, gesture, screenshot, snapshot) moved into @agent-device/contracts, the port could be about 30 lines of interface over shared types. That would be a follow-up type move like #3394, not part of this PR.

@thymikee
thymikee force-pushed the refactor/daemon-command-surface-injection branch from f21198a to d297702 Compare October 11, 2026 07:01
@thymikee

Copy link
Copy Markdown
Member Author

Rebased onto the rebased refactor/command-runtime-contracts (base head aa31c3810, main incl. #3400); only this PR's commits replayed, no conflicts.

Eager closure fix. The static createCommandSurfaceAgentDevice import in src/daemon.ts pulled the command surface back into the entry's eager closure: the budget test reported 550 modules vs the merge-base's 470. src/daemon.ts now injects loadCommandSurface: async () => (await import('./command-runtime/runtime-command-surface.ts')).createCommandSurfaceAgentDevice — required, no fallback — and startDaemonRuntime (already async) awaits it once at startup and threads the sync factory through the existing chain, so construction sites and test bags are unchanged. Closure: 550 → 470, exactly at the merge-base; eager-closure budgets green (776 tests).

Function-typed members (taken). DaemonCommandSurfaceCapture/Selectors/Interactions members are now function-typed properties, so strictFunctionTypes checks them contravariantly. It immediately reported the real mismatch: the port declared DaemonGetOptions.target as InteractionTarget while the implementation's get takes an ElementTarget — 515 assignability failures through the test bags. The port narrows target to ElementTarget, which matches the only call site (parseGetTarget yields ref | selector). Compiler clean.

Type mirror (deferred). Agreeing: one declaration per command option/result shape, in contracts, with src/commands importing it. Moving the option/result types the daemon calls (wait, find/get/is, press/fill/longPress/hover, gesture, screenshot, snapshot) into @agent-device/contracts is a follow-up type move like #3394, not part of this PR.

No new production daemon/** files, and #3391's layer manifest isn't in main yet, so no manifest edit. Re-verified with pnpm depgraph: production daemon/** has zero edges into command-runtime/** or commands/**. Gates serially green: format, check:quick, check:layering, check:di-seams, eager-closure budgets, test-file-size ratchet, check:affected --run.

Stays draft until #3394 merges.

@thymikee

Copy link
Copy Markdown
Member Author

Follow-up on the eager-closure fix (b617d12): the startup-await variant still evaluated the whole surface on every daemon start. CreateDaemonCommandSurface is now an async factory — src/daemon.ts passes createCommandSurface: async (config) => (await import('./command-runtime/runtime-command-surface.ts')).createCommandSurfaceAgentDevice(config) — and the four construction sites (interaction, screenshot, selector, snapshot runtimes) await the port, so the surface loads on the first request that needs it, as before #3373. The loadCommandSurface option and the startup await are gone; module caching keeps later calls cheap. Call sites all sit in async request paths; createInteractionRuntimeForRoute/createInteractionRuntime and the snapshot/selector builders became async, with awaited call sites updated. Only the shared test fixture changed shape; the param bags keep the field name. Eager closure holds at 470 (= merge-base); eager-closure budgets, check:quick, check:layering, check:di-seams, the size ratchet, and check:affected --run all green.

@thymikee
thymikee added this pull request to stack #3411 October 11, 2026 08:28
@thymikee

Copy link
Copy Markdown
Member Author

The code in b617d12 looks good to me, and the earlier open question from f21198a is now resolved.

The only evidence still outstanding is the production route. Smoke Tests are queued and they are the only check that reaches it. They spawn the built daemon, so the first surface command runs the composition-root lambda in src/daemon.ts and its dynamic import from dist. No unit test calls that lambda, because every test bag uses the static import in command-surface-fixture.ts. I did not run tsc, the eager-closure budget test or depgraph, so the 550-to-470 closure count and the zero-edge claim rest on your report and on my read of the import sites. CodeQL Analyze is also pending, and this change has no data-flow overlap with it.

I know of no conflicts. Before merge, Smoke Tests must go green on b617d12, and #3394 must merge so this PR can be rebased onto main.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 11, 2026
@thymikee
thymikee marked this pull request as ready for review October 11, 2026 09:49

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 175 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/daemon.ts">

<violation number="1" location="src/daemon.ts:5">
P3: This comment restates the dynamic-import behavior shown immediately below, contrary to `AGENTS.md`'s no-control-flow-narration rule. Replace it with the non-obvious reason for keeping this import lazy.</violation>
</file>

<file name="src/daemon/interaction/internal/interaction-touch-runtime.ts">

<violation number="1" location="src/daemon/interaction/internal/interaction-touch-runtime.ts:75">
P3: `actionStartedAt` is now stamped *after* the awaited port call on line 75, so the first-use command-surface module load (the `await import` in `src/daemon.ts`) is excluded from the touch action's recorded span — while `dispatchGestureInteraction` stamps its start at function entry and includes the same load. First-request durations for touch vs gesture interactions are therefore not comparable. Move `const actionStartedAt = Date.now();` to before the runtime construction (matching the gesture path) if the span is meant to cover the whole request-side interaction work.</violation>
</file>

<file name="packages/contracts/src/daemon-command-surface.ts">

<violation number="1" location="packages/contracts/src/daemon-command-surface.ts:180">
P2: `DaemonWaitResult` collapses the wait command's discriminated union, so `kind === 'text'` still leaves `text` optional and permits impossible result shapes. Preserve the per-kind variants so consumers and injected fakes can rely on the discriminator.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment on lines +180 to +190
export type DaemonWaitResult = Readonly<{
kind: 'sleep' | 'text' | 'selector' | 'absent' | 'stable';
waitedMs: number;
text?: string;
selector?: string;
captures?: number;
nodeCount?: number;
hint?: string;
node?: SnapshotNode;
preActionNodes?: SnapshotNode[];
}>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: DaemonWaitResult collapses the wait command's discriminated union, so kind === 'text' still leaves text optional and permits impossible result shapes. Preserve the per-kind variants so consumers and injected fakes can rely on the discriminator.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/contracts/src/daemon-command-surface.ts, line 180:

<comment>`DaemonWaitResult` collapses the wait command's discriminated union, so `kind === 'text'` still leaves `text` optional and permits impossible result shapes. Preserve the per-kind variants so consumers and injected fakes can rely on the discriminator.</comment>

<file context>
@@ -0,0 +1,370 @@
+  DaemonSelectorSnapshotOptions &
+  Readonly<{ target: DaemonWaitTarget }>;
+
+export type DaemonWaitResult = Readonly<{
+  kind: 'sleep' | 'text' | 'selector' | 'absent' | 'stable';
+  waitedMs: number;
</file context>
Suggested change
export type DaemonWaitResult = Readonly<{
kind: 'sleep' | 'text' | 'selector' | 'absent' | 'stable';
waitedMs: number;
text?: string;
selector?: string;
captures?: number;
nodeCount?: number;
hint?: string;
node?: SnapshotNode;
preActionNodes?: SnapshotNode[];
}>;
export type DaemonWaitResult =
| Readonly<{ kind: 'sleep'; waitedMs: number }>
| Readonly<{ kind: 'text'; waitedMs: number; text: string }>
| Readonly<{
kind: 'selector';
waitedMs: number;
selector: string;
node?: SnapshotNode;
preActionNodes?: SnapshotNode[];
}>
| Readonly<{ kind: 'absent'; waitedMs: number }>
| Readonly<{
kind: 'stable';
waitedMs: number;
captures: number;
nodeCount: number;
hint?: string;
}>;

Comment thread src/daemon.ts

void startDaemonRuntime().catch((error) => {
void startDaemonRuntime({
// The composition root's surface stays behind `await import`, loaded on first request use.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This comment restates the dynamic-import behavior shown immediately below, contrary to AGENTS.md's no-control-flow-narration rule. Replace it with the non-obvious reason for keeping this import lazy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon.ts, line 5:

<comment>This comment restates the dynamic-import behavior shown immediately below, contrary to `AGENTS.md`'s no-control-flow-narration rule. Replace it with the non-obvious reason for keeping this import lazy.</comment>

<file context>
@@ -1,7 +1,13 @@
 
-void startDaemonRuntime().catch((error) => {
+void startDaemonRuntime({
+  // The composition root's surface stays behind `await import`, loaded on first request use.
+  createCommandSurface: async (config) =>
+    (await import('./command-runtime/runtime-command-surface.ts')).createCommandSurfaceAgentDevice(
</file context>
Suggested change
// The composition root's surface stays behind `await import`, loaded on first request use.
// Keep command-runtime out of the daemon's eager import graph.

const session = params.sessionStore.requireCurrent(params.sessionRef);
const consumedCapture: { state?: SnapshotState } = {};
const runtime = createInteractionRuntimeForRoute({
const runtime = await createInteractionRuntimeForRoute({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: actionStartedAt is now stamped after the awaited port call on line 75, so the first-use command-surface module load (the await import in src/daemon.ts) is excluded from the touch action's recorded span — while dispatchGestureInteraction stamps its start at function entry and includes the same load. First-request durations for touch vs gesture interactions are therefore not comparable. Move const actionStartedAt = Date.now(); to before the runtime construction (matching the gesture path) if the span is meant to cover the whole request-side interaction work.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/daemon/interaction/internal/interaction-touch-runtime.ts, line 75:

<comment>`actionStartedAt` is now stamped *after* the awaited port call on line 75, so the first-use command-surface module load (the `await import` in `src/daemon.ts`) is excluded from the touch action's recorded span — while `dispatchGestureInteraction` stamps its start at function entry and includes the same load. First-request durations for touch vs gesture interactions are therefore not comparable. Move `const actionStartedAt = Date.now();` to before the runtime construction (matching the gesture path) if the span is meant to cover the whole request-side interaction work.</comment>

<file context>
@@ -72,7 +72,7 @@ export async function dispatchRuntimeInteraction<
   const session = params.sessionStore.requireCurrent(params.sessionRef);
   const consumedCapture: { state?: SnapshotState } = {};
-  const runtime = createInteractionRuntimeForRoute({
+  const runtime = await createInteractionRuntimeForRoute({
     ...params,
     touchExecutor: options.touchExecutor,
</file context>

…ed port

The daemon no longer imports createCommandSurfaceAgentDevice; the composition
root (src/daemon.ts) supplies it through the required createCommandSurface
field threaded from RequestRouterDeps down to every execution runtime. The
port shape lives in @agent-device/contracts/daemon-command-surface so naming
it costs daemon files no type edge into the command families.
…contravariantly

- src/daemon.ts injects loadCommandSurface behind await import so the command
  surface stays out of the daemon entry's eager closure (470 modules, at the
  merge-base; the static import reached 550). startDaemonRuntime awaits the
  loader once and threads the sync factory unchanged.
- DaemonCommandSurface{Capture,Selectors,Interactions} members become
  function-typed properties so strictFunctionTypes checks them
  contravariantly. The check found the port's DaemonGetOptions.target was
  InteractionTarget while the implementation takes ElementTarget; the port
  narrows to ElementTarget, which matches the one call site (ref | selector).
@thymikee

Copy link
Copy Markdown
Member Author

Follow-up on b617d12, same commit as my earlier review. Two things now block this PR.

First, the branch conflicts with main after today's merges. Please rebase.

Second, the open Cubic thread on DaemonWaitResult (packages/contracts/src/daemon-command-surface.ts:180) holds. On main, the wait runtime returns a discriminated union: kind: 'text' carries a required text, kind: 'stable' carries captures and nodeCount, and so on. The new port type flattens this into one object with every field optional. A refactor that moves the contract should keep its shape, so a consumer or an injected fake can rely on kind. The smallest fix is to copy the per-kind variants into the contracts type. Typecheck covers it, no device run needed.

Not blocking: the two P3 Cubic notes (the narrating comment in src/daemon.ts and where actionStartedAt is stamped in interaction-touch-runtime.ts) are worth a look in the same pass.

I removed ready-for-human until the rebase and the type fix land.

@thymikee thymikee removed the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Oct 11, 2026
…at startup

CreateDaemonCommandSurface becomes an async factory: the composition root's
lambda keeps the surface behind await import, and the four construction sites
await the port, so a daemon that never serves a surface command never
evaluates it. startDaemonRuntime's startup await and the LoadDaemonCommandSurface
seam go away; the test fixture wraps the real factory in an async lambda and
the test bags keep the createCommandSurface field name.
@thymikee
thymikee force-pushed the refactor/daemon-command-surface-injection branch from b617d12 to 7c4f6b7 Compare October 11, 2026 10:37
@thymikee
thymikee merged commit 64f3e65 into main Oct 11, 2026
19 checks passed
@thymikee
thymikee deleted the refactor/daemon-command-surface-injection branch October 11, 2026 10:45
@github-actions

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-11 10:46 UTC

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant