Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/contracts/src/device-rotation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { AppError } from '@agent-device/kernel/errors';

/** Single source of truth for the discriminator the Apple owner sets and the MCP schema advertises. */
export const FOLD_SCREEN_COORDINATE_SPACE = 'native-panel' as const;
export type FoldScreenCoordinateSpace = typeof FOLD_SCREEN_COORDINATE_SPACE;

export const DEVICE_ROTATIONS = [
'portrait',
'portrait-upside-down',
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type {
export {
DEVICE_ROTATIONS,
DEVICE_ROTATION_SURFACE_INDEX,
FOLD_SCREEN_COORDINATE_SPACE,
MAX_FOLD_DURATION_MS,
MAX_FOLD_KEYFRAMES,
parseFoldInput,
Expand All @@ -47,6 +48,7 @@ export type {
DeviceRotation,
FoldPose,
FoldKeyframe,
FoldScreenCoordinateSpace,
SetFoldPoseInput,
} from '../device-rotation.ts';
export type { BootCommandResult, ShutdownCommandResult } from '../device.ts';
Expand Down
15 changes: 10 additions & 5 deletions packages/contracts/src/fold-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import type { FoldPose, SetFoldPoseInput } from './device-rotation.ts';
import type { FoldPose, FoldScreenCoordinateSpace, SetFoldPoseInput } from './device-rotation.ts';

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This removes FOLD_SCREEN_COORDINATE_SPACE from the published @agent-device/contracts/fold-runtime subpath, breaking existing consumers that import the value there. Preserve the compatibility re-export from device-rotation.ts while using the relocated constant internally.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/contracts/src/fold-runtime.ts, line 1:

<comment>This removes `FOLD_SCREEN_COORDINATE_SPACE` from the published `@agent-device/contracts/fold-runtime` subpath, breaking existing consumers that import the value there. Preserve the compatibility re-export from `device-rotation.ts` while using the relocated constant internally.</comment>

<file context>
@@ -1,13 +1,10 @@
-import type { FoldPose, SetFoldPoseInput } from './device-rotation.ts';
-import { FOLD_SCREEN_COORDINATE_SPACE } from './device-rotation.ts';
+import type { FoldPose, FoldScreenCoordinateSpace, SetFoldPoseInput } from './device-rotation.ts';
 import type { RuntimeOperationFact } from './platform-runtime.ts';
 
</file context>
Suggested change
import type { FoldPose, FoldScreenCoordinateSpace, SetFoldPoseInput } from './device-rotation.ts';
import type { FoldPose, FoldScreenCoordinateSpace, SetFoldPoseInput } from './device-rotation.ts';
export { FOLD_SCREEN_COORDINATE_SPACE } from './device-rotation.ts';
Fix with cubic

import type { RuntimeOperationFact } from './platform-runtime.ts';

/** Single source of truth for the discriminator the Apple owner sets and the MCP schema advertises. */
export const FOLD_SCREEN_COORDINATE_SPACE = 'native-panel' as const;
/**
* Mirrors `device-rotation.ts`'s `FOLD_SCREEN_COORDINATE_SPACE` (the single source of truth) as a
* literal rather than a re-export, so importing this published subpath does not also eagerly load
* `device-rotation.ts`. The `FoldScreenCoordinateSpace` type import above still makes any drift
* between the two a compile error.
*/
export const FOLD_SCREEN_COORDINATE_SPACE: FoldScreenCoordinateSpace = 'native-panel';

/**
* The panel the device lights after the pose settled, in that panel's own native points: its pixel
* size divided by its point scale, never rotated. `coordinateSpace` is always
* {@link FOLD_SCREEN_COORDINATE_SPACE}, and these numbers are NOT snapshot coordinates — the active
* {@link FoldScreenCoordinateSpace}, and these numbers are NOT snapshot coordinates — the active
* app window can differ from the panel (iPhone Duo: a 669x951 inner panel hosts a 951x669 app
* window), so they cannot place a tap. A caller that needs the app viewport must take a fresh
* snapshot.
Expand All @@ -16,7 +21,7 @@ export type FoldScreenReport = Readonly<{
/** The CoreDevice display name of the panel the device now lights. */
display: string;
/** Marks these dimensions as the panel's native points, never a snapshot's app viewport. */
coordinateSpace: typeof FOLD_SCREEN_COORDINATE_SPACE;
coordinateSpace: FoldScreenCoordinateSpace;
widthPt: number;
heightPt: number;
}>;
Expand Down
7 changes: 2 additions & 5 deletions packages/platform-apple/src/foldable/pose.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import {
type SetFoldPoseInput,
foldPoseForHingeAngle,
FOLD_SCREEN_COORDINATE_SPACE,
type FoldPose,
} from '@agent-device/contracts/device';
import {
FOLD_SCREEN_COORDINATE_SPACE,
type FoldScreenReport,
type SetFoldPoseResult,
} from '@agent-device/contracts/fold-runtime';
import type { FoldScreenReport, SetFoldPoseResult } from '@agent-device/contracts/fold-runtime';
import type { DeviceInfo } from '@agent-device/kernel/device';
import { AppError } from '@agent-device/kernel/errors';
import { emitDiagnostic } from '@agent-device/host-kit/diagnostics';
Expand Down
5 changes: 5 additions & 0 deletions src/commands/command-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ export function enumSchema(values: readonly string[], description?: string): Jso
return { type: 'string', enum: values, ...(description ? { description } : {}) };
}

/** A single-valued string schema, for a discriminant field an output shape always fixes. */
export function constSchema(value: string): JsonSchema {
return { type: 'string', const: value };
}

export function stringSchema(description?: string): JsonSchema {
return { type: 'string', ...(description ? { description } : {}) };
}
Expand Down
159 changes: 159 additions & 0 deletions src/commands/system/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FOLD_FLAGS } from '@agent-device/command-registry/flag-groups';
import type { CommandResultMap } from '@agent-device/command-registry/command-result';
import type { ClipboardCommandOptions } from '@agent-device/contracts/client';
import {
type FoldKeyframe,
FOLD_SCREEN_COORDINATE_SPACE,
MAX_FOLD_DURATION_MS,
MAX_FOLD_KEYFRAMES,
parseFoldInput,
Expand All @@ -18,6 +20,8 @@ import {
parseTvRemoteButton,
tvRemoteDurationMode,
} from '@agent-device/contracts/tv-remote';
import { APPLE_APPLICATION_STATES } from '@agent-device/kernel/snapshot';
import { SESSION_SURFACES } from '@agent-device/contracts/session';
import { AppError } from '@agent-device/kernel/errors';
import type { CommandSchemaOverride } from '@agent-device/command-registry/command-schema';
import {
Expand All @@ -28,13 +32,20 @@ import {
requiredDaemonString,
} from '../cli-grammar/common.ts';
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import type { JsonSchema } from '../command-contract.ts';
import {
enumField,
integerField,
requiredField,
stringField,
jsonSchemaField,
readFieldInput,
booleanSchema,
constSchema,
enumSchema,
numberSchema,
objectSchema,
stringSchema,
} from '../command-input.ts';
import { compactRecord } from '../input-readers.ts';
import {
Expand Down Expand Up @@ -64,6 +75,154 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500;
const CLIPBOARD_ACTION_VALUES = ['read', 'write'] as const;
const KEYBOARD_METADATA_ACTION_VALUES = ['status', 'dismiss', 'enter', 'return'] as const;

/**
* This family's advertised MCP `outputSchema`s, keyed by daemon command name and projected into
* the command map by `src/mcp/command-output-schemas.ts`. Non-strict like every other entry: no
* `additionalProperties: false`, so additive response fields such as `settle`/`cost` keep
* validating. `back`'s settle observation is grafted separately by the trait derivation pass.
*/
export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = {

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The change is described as exporting a "frozen" map, but satisfies only constrains types — the object and every nested schema stay mutable at runtime, and the new reference-equality tests share the same reference so an in-place mutation would pass unnoticed. Either drop the "frozen" characterization or actually free the map (deep-Object.freeze) if immutability is the intent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/system/index.ts, line 87:

<comment>The change is described as exporting a "frozen" map, but `satisfies` only constrains types — the object and every nested schema stay mutable at runtime, and the new reference-equality tests share the same reference so an in-place mutation would pass unnoticed. Either drop the "frozen" characterization or actually free the map (deep-`Object.freeze`) if immutability is the intent.</comment>

<file context>
@@ -64,6 +74,158 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500;
+ * `additionalProperties: false`, so additive response fields such as `settle`/`cost` keep
+ * validating. `back`'s settle observation is grafted separately by the trait derivation pass.
+ */
+export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = {
+  back: objectSchema(
+    {
</file context>
Fix with cubic

back: objectSchema(
{
action: constSchema('back'),
mode: enumSchema(BACK_MODES),
message: stringSchema(),
},
['action', 'mode', 'message'],
),
home: objectSchema({ action: constSchema('home'), message: stringSchema() }, [
'action',
'message',
]),
orientation: objectSchema(
{
action: constSchema('orientation'),
orientation: enumSchema(DEVICE_ROTATIONS),
message: stringSchema(),
confirmed: booleanSchema(),
warning: stringSchema(),
},
['action', 'orientation', 'message'],
),
'app-switcher': objectSchema({ action: constSchema('app-switcher'), message: stringSchema() }, [
'action',
'message',
]),
fold: objectSchema(
{
action: constSchema('fold'),
pose: enumSchema(FOLD_POSES),
hingeAngleDegrees: numberSchema('Hinge angle CoreDevice read back after the pose settled.'),
screen: objectSchema(
{
display: stringSchema('CoreDevice name of the panel the device now lights.'),
coordinateSpace: constSchema(FOLD_SCREEN_COORDINATE_SPACE),
widthPt: numberSchema(
'Panel width in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.',
),
heightPt: numberSchema(
'Panel height in native panel points (pixels divided by point scale), NOT snapshot coordinates; take a fresh snapshot to place a tap.',
),
},
['display', 'coordinateSpace', 'widthPt', 'heightPt'],
),
message: stringSchema(),
},
['action', 'pose', 'hingeAngleDegrees', 'message'],
),
'action-button': objectSchema({ action: constSchema('action-button'), message: stringSchema() }, [
'action',
'message',
]),
'tv-remote': objectSchema(
{
action: constSchema('tv-remote'),
button: enumSchema(TV_REMOTE_BUTTONS),
durationMs: numberSchema(),
message: stringSchema(),
},
['action', 'button', 'message'],
),
// packages/contracts/src/clipboard.ts — discriminated union on `action`.
clipboard: {
type: 'object',
oneOf: [
objectSchema({ action: constSchema('read'), text: stringSchema() }, ['action', 'text']),
objectSchema(
{ action: constSchema('write'), textLength: numberSchema(), message: stringSchema() },
['action', 'textLength', 'message'],
),
],
},
// packages/contracts/src/app-state.ts — discriminated union on `platform`.
appstate: {
type: 'object',
oneOf: [
objectSchema(
{
platform: enumSchema(['ios', 'macos']),
appName: stringSchema(),
appBundleId: stringSchema(),
source: enumSchema(
['session', 'runner'],
'runner when a live runner read the session app state; session when the record alone answered.',
),
state: enumSchema(
APPLE_APPLICATION_STATES,
'The session app XCUIApplication state as a live runner reads it; absent with source session.',
),
surface: enumSchema(SESSION_SURFACES),
device_udid: stringSchema('iOS only — the session device UDID.'),
ios_simulator_device_set: {
type: ['string', 'null'],
description: 'iOS only — the simulator set path, or null when unknown.',
},
},
['platform', 'appName', 'source', 'surface'],
),
objectSchema(
{
platform: constSchema('android'),

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The appstate schema rejects successful HarmonyOS responses. Use the same android | harmonyos platform vocabulary as AppStateCommandResult so MCP clients can validate HarmonyOS appstate results.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/system/index.ts, line 188:

<comment>The appstate schema rejects successful HarmonyOS responses. Use the same `android | harmonyos` platform vocabulary as `AppStateCommandResult` so MCP clients can validate HarmonyOS appstate results.</comment>

<file context>
@@ -64,6 +74,158 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500;
+      ),
+      objectSchema(
+        {
+          platform: constSchema('android'),
+          package: stringSchema(),
+          activity: stringSchema(),
</file context>
Suggested change
platform: constSchema('android'),
platform: enumSchema(['android', 'harmonyos']),
Fix with cubic

package: stringSchema(),
activity: stringSchema(),
},
['platform', 'package', 'activity'],
),
],
},
// packages/contracts/src/keyboard.ts — flat closed shape; `platform`/`action` always present.
keyboard: objectSchema(
{
platform: enumSchema(['android', 'ios']),

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The keyboard schema rejects HarmonyOS dismiss and enter results. Add harmonyos to the platform enum so the MCP output schema accepts every platform the keyboard runtime returns.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/system/index.ts, line 199:

<comment>The keyboard schema rejects HarmonyOS dismiss and enter results. Add `harmonyos` to the platform enum so the MCP output schema accepts every platform the keyboard runtime returns.</comment>

<file context>
@@ -64,6 +74,158 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500;
+  // packages/contracts/src/keyboard.ts — flat closed shape; `platform`/`action` always present.
+  keyboard: objectSchema(
+    {
+      platform: enumSchema(['android', 'ios']),
+      action: enumSchema(['status', 'dismiss', 'enter']),
+      visible: booleanSchema(),
</file context>
Suggested change
platform: enumSchema(['android', 'ios']),
platform: enumSchema(['android', 'harmonyos', 'ios']),
Fix with cubic

action: enumSchema(['status', 'dismiss', 'enter']),
visible: booleanSchema(),
wasVisible: booleanSchema(),
dismissed: booleanSchema(),
attempts: numberSchema(),
inputType: stringSchema(),
type: enumSchema(['text', 'number', 'email', 'phone', 'password', 'datetime', 'unknown']),
inputMethodPackage: stringSchema(),
focusedPackage: stringSchema(),
focusedResourceId: stringSchema(),
inputOwner: enumSchema(['app', 'ime', 'unknown']),

@cubic-dev-ai cubic-dev-ai Bot Sep 28, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The keyboard output schema omits the contract's mechanism field, so MCP consumers cannot discover the iOS dismiss-key disclosure from outputSchema. Add an optional mechanism enum for dismissKey.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/system/index.ts, line 210:

<comment>The keyboard output schema omits the contract's `mechanism` field, so MCP consumers cannot discover the iOS dismiss-key disclosure from `outputSchema`. Add an optional `mechanism` enum for `dismissKey`.</comment>

<file context>
@@ -64,6 +74,158 @@ const TV_REMOTE_LONGPRESS_PRESET_MS = 500;
+      inputMethodPackage: stringSchema(),
+      focusedPackage: stringSchema(),
+      focusedResourceId: stringSchema(),
+      inputOwner: enumSchema(['app', 'ime', 'unknown']),
+      message: stringSchema(),
+    },
</file context>
Suggested change
inputOwner: enumSchema(['app', 'ime', 'unknown']),
inputOwner: enumSchema(['app', 'ime', 'unknown']),
mechanism: enumSchema(['dismissKey']),
Fix with cubic

message: stringSchema(),
},
['platform', 'action'],
),
} satisfies Pick<
Record<keyof CommandResultMap, JsonSchema>,
| 'back'
| 'home'
| 'orientation'
| 'app-switcher'
| 'fold'
| 'action-button'
| 'tv-remote'
| 'clipboard'
| 'appstate'
| 'keyboard'
>;

const appStateCommandDescription =
'Show foreground app/activity (Android; iOS answers per command)';
const backCommandDescription =
Expand Down
42 changes: 0 additions & 42 deletions src/mcp/__tests__/command-tools-appstate-schema.test.ts

This file was deleted.

Loading
Loading