@intentic/sandbox-contract 1.338.0 → 1.340.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.
Files changed (75) hide show
  1. package/README.md +5 -0
  2. package/dist/contracts/agents.contract.d.ts +4 -0
  3. package/dist/contracts/agents.contract.d.ts.map +1 -1
  4. package/dist/contracts/agents.contract.js +10 -1
  5. package/dist/contracts/agents.contract.js.map +1 -1
  6. package/dist/contracts/phone.contract.d.ts +1 -0
  7. package/dist/contracts/phone.contract.d.ts.map +1 -1
  8. package/dist/contracts/system.contract.d.ts +3 -0
  9. package/dist/contracts/system.contract.d.ts.map +1 -1
  10. package/dist/index.d.ts +7 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/models/agent-lanes.d.ts +30 -0
  13. package/dist/models/agent-lanes.d.ts.map +1 -0
  14. package/dist/models/agent-lanes.js +67 -0
  15. package/dist/models/agent-lanes.js.map +1 -0
  16. package/dist/models/agent-standing.d.ts +55 -0
  17. package/dist/models/agent-standing.d.ts.map +1 -0
  18. package/dist/models/agent-standing.js +82 -0
  19. package/dist/models/agent-standing.js.map +1 -0
  20. package/dist/models/agent-starter.d.ts +10 -0
  21. package/dist/models/agent-starter.d.ts.map +1 -0
  22. package/dist/models/agent-starter.js +16 -0
  23. package/dist/models/agent-starter.js.map +1 -0
  24. package/dist/netd/generated/wire.d.ts +1 -1
  25. package/dist/netd/generated/wire.d.ts.map +1 -1
  26. package/dist/policy/persona-capability.d.ts +14 -0
  27. package/dist/policy/persona-capability.d.ts.map +1 -0
  28. package/dist/policy/persona-capability.js +23 -0
  29. package/dist/policy/persona-capability.js.map +1 -0
  30. package/dist/protocol/doors/phone-links.d.ts +5 -0
  31. package/dist/protocol/doors/phone-links.d.ts.map +1 -1
  32. package/dist/protocol/doors/phone-links.js +3 -1
  33. package/dist/protocol/doors/phone-links.js.map +1 -1
  34. package/dist/protocol/raw/raw-json-routes.d.ts +1 -0
  35. package/dist/protocol/raw/raw-json-routes.d.ts.map +1 -1
  36. package/dist/schemas/agents-list.d.ts +5 -0
  37. package/dist/schemas/agents-list.d.ts.map +1 -1
  38. package/dist/schemas/agents-list.js +8 -0
  39. package/dist/schemas/agents-list.js.map +1 -1
  40. package/dist/schemas/devices.d.ts +9 -0
  41. package/dist/schemas/devices.d.ts.map +1 -1
  42. package/dist/schemas/devices.js +3 -0
  43. package/dist/schemas/devices.js.map +1 -1
  44. package/dist/schemas/intentic-os.d.ts +218 -0
  45. package/dist/schemas/intentic-os.d.ts.map +1 -1
  46. package/dist/schemas/intentic-os.js +166 -0
  47. package/dist/schemas/intentic-os.js.map +1 -1
  48. package/dist/schemas/phone.d.ts +3 -0
  49. package/dist/schemas/phone.d.ts.map +1 -1
  50. package/dist/schemas/phone.js +1 -0
  51. package/dist/schemas/phone.js.map +1 -1
  52. package/dist/schemas/system.d.ts +2 -0
  53. package/dist/schemas/system.d.ts.map +1 -1
  54. package/dist/schemas/system.js +1 -1
  55. package/dist/schemas/system.js.map +1 -1
  56. package/dist/state/history-state.d.ts.map +1 -1
  57. package/dist/state/history-state.js +1 -0
  58. package/dist/state/history-state.js.map +1 -1
  59. package/package.json +5 -5
  60. package/src/contracts/agents.contract.ts +13 -1
  61. package/src/models/agent-lanes.ts +152 -0
  62. package/src/models/agent-standing.ts +279 -0
  63. package/src/models/agent-starter.ts +26 -0
  64. package/src/netd/generated/netd-wire.json +2 -1
  65. package/src/netd/generated/wire.ts +1 -1
  66. package/src/policy/persona-capability.ts +57 -0
  67. package/src/protocol/doors/phone-links.ts +11 -2
  68. package/src/protocol/doors/phone-protocol.test.ts +8 -1
  69. package/src/schemas/agents-list.ts +13 -0
  70. package/src/schemas/devices.ts +7 -0
  71. package/src/schemas/intentic-os.test.ts +148 -0
  72. package/src/schemas/intentic-os.ts +259 -0
  73. package/src/schemas/phone.ts +3 -0
  74. package/src/schemas/system.ts +5 -2
  75. package/src/state/history-state.ts +3 -0
@@ -18,6 +18,10 @@ const PAIRING_PREFIX = "ixp1_";
18
18
  // that offers it. The code rides the fragment, which no server ever receives, so no access log holds a live pairing.
19
19
  export const PHONE_PAIR_LINK = "https://intentic.dev/phone/pair";
20
20
 
21
+ // A watch has no camera to scan with, so its code is scanned by the phone beside it: this link opens Intentic Device on
22
+ // that phone, which sends the code on to the watch instead of pairing itself. The code inside is the same.
23
+ export const WATCH_PAIR_LINK = "https://intentic.dev/watch/pair";
24
+
21
25
  const base64url = (value: string): string => btoa(value).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
22
26
 
23
27
  export const phonePairingCode = (pairing: { readonly url: string; readonly token: string }): string =>
@@ -26,12 +30,17 @@ export const phonePairingCode = (pairing: { readonly url: string; readonly token
26
30
  export const phonePairingLink = (pairing: { readonly url: string; readonly token: string }): string =>
27
31
  `${PHONE_PAIR_LINK}#${phonePairingCode(pairing)}`;
28
32
 
33
+ export const watchPairingLink = (pairing: { readonly url: string; readonly token: string }): string =>
34
+ `${WATCH_PAIR_LINK}#${phonePairingCode(pairing)}`;
35
+
29
36
  // The phone's side, and the editor's for a pasted code; undefined for anything not one of ours. Accepts the bare code
30
- // or the whole link, since a person may paste either.
37
+ // or the whole link (a phone's or a watch's), since a person may paste either.
31
38
  export const parsePhonePairingCode = (input: string): { readonly url: string; readonly token: string } | undefined => {
32
39
  const trimmed = input.trim();
33
40
  // The site serves the page with a trailing slash, so a link copied out of the browser may carry one.
34
- const linked = [`${PHONE_PAIR_LINK}#`, `${PHONE_PAIR_LINK}/#`].find((prefix) => trimmed.startsWith(prefix));
41
+ const linked = [`${PHONE_PAIR_LINK}#`, `${PHONE_PAIR_LINK}/#`, `${WATCH_PAIR_LINK}#`, `${WATCH_PAIR_LINK}/#`].find((prefix) =>
42
+ trimmed.startsWith(prefix),
43
+ );
35
44
  const code = linked === undefined ? trimmed : trimmed.slice(linked.length);
36
45
  if (!code.startsWith(PAIRING_PREFIX)) {
37
46
  return undefined;
@@ -4,7 +4,7 @@ import { packageRoot } from "@intentic/constants/node";
4
4
  import { OkSchema } from "../../schemas/shared.js";
5
5
  import { PhoneFactsSchema, PhoneScopesSchema } from "../../schemas/phone.js";
6
6
  import { PHONE_ERROR, PhoneHelloSchema, PhoneRequestSchema, PhoneResponseSchema } from "./phone-protocol.js";
7
- import { parsePhonePairingCode, phoneConnectUrl, phoneEnrollUrl, phonePairingCode, phonePairingLink } from "./phone-links.js";
7
+ import { parsePhonePairingCode, phoneConnectUrl, phoneEnrollUrl, phonePairingCode, phonePairingLink, watchPairingLink } from "./phone-links.js";
8
8
 
9
9
  // The phone door's wire as examples, spelled once here: golden/phone-wire.json is this file's `wire` as JSON, and both
10
10
  // far ends test against it — the daemon's JSON-RPC link (_sandbox/sandbox/src/phones/json-rpc-link.test.ts) sends
@@ -123,6 +123,13 @@ test("a pairing round-trips as a code and as the link the QR carries", () => {
123
123
  expect(parsePhonePairingCode(phonePairingLink(pairing).replace("/pair#", "/pair/#"))).toEqual(pairing);
124
124
  });
125
125
 
126
+ test("a watch's link carries the same code, under the path its phone hands on", () => {
127
+ const pairing = { url: "https://sandbox-abc.sbx.intentic.dev", token: "one-time" };
128
+ expect(watchPairingLink(pairing)).toBe(`https://intentic.dev/watch/pair#${phonePairingCode(pairing)}`);
129
+ expect(parsePhonePairingCode(watchPairingLink(pairing))).toEqual(pairing);
130
+ expect(parsePhonePairingCode(watchPairingLink(pairing).replace("/pair#", "/pair/#"))).toEqual(pairing);
131
+ });
132
+
126
133
  test("a code that is not ours, or carries no usable url or token, is refused rather than attempted", () => {
127
134
  expect(parsePhonePairingCode("ixb1_abc")).toBeUndefined();
128
135
  expect(parsePhonePairingCode("ixp1_not-base64!")).toBeUndefined();
@@ -22,3 +22,16 @@ export const AgentsListSchema = z.object({
22
22
  ),
23
23
  });
24
24
  export type AgentsList = z.infer<typeof AgentsListSchema>;
25
+
26
+ // What the fleet owes its owner, counted by the board's own rules (models/agent-lanes.ts `fleetAttention`): what the
27
+ // desktop app's status bar and `ic sandbox attention` read over the loopback port, so neither has to fetch the whole
28
+ // roster and fold it the way the board does.
29
+ export const AgentsAttentionSchema = z.object({
30
+ needsYou: z
31
+ .number()
32
+ .int()
33
+ .nonnegative()
34
+ .describe("How many cards the board's Attention lane draws (a child agent counted through its family's one card), plus the automations waiting for a yes."),
35
+ working: z.number().int().nonnegative().describe("How many conversations are producing something right now."),
36
+ });
37
+ export type AgentsAttention = z.infer<typeof AgentsAttentionSchema>;
@@ -104,6 +104,13 @@ export const DeviceSandboxSchema = z.object({
104
104
  // (2026-10-10) Stopped by the machine's keeper after a stretch nobody needed it (`ic sandbox sleep`), and started
105
105
  // again by itself when somebody opens it. `running` is false while this is true.
106
106
  asleep: z.boolean().optional(),
107
+ // (2026-10-16) How this machine reaches it with no network (ic's sandbox/loopback.rs): its 12-hex id from its connect
108
+ // token (null without one), the host port its loopback listener is published on (null when it publishes none), and
109
+ // the platform's own id for its row as its last announce said (null before one did). Absent from an older ic, and
110
+ // from a row whose container docker would not inspect.
111
+ sandboxId: z.string().nullable().optional(),
112
+ localPort: z.number().int().nullable().optional(),
113
+ platformId: z.string().nullable().optional(),
107
114
  });
108
115
  export type DeviceSandbox = z.infer<typeof DeviceSandboxSchema>;
109
116
  // One operation on one sandbox, streamed as lines ending in a `result` or `error` frame. `prepare` builds the pending
@@ -3,7 +3,39 @@ import { join } from "node:path";
3
3
  import { packageRoot } from "@intentic/constants/node";
4
4
  import { DeviceFactsSchema } from "./hosts.js";
5
5
  import {
6
+ displayModeArg,
7
+ IC_OS_DRIVE_NO_ROOM_EXIT,
8
+ IC_OS_NO_SCREEN_EXIT,
9
+ IC_OS_NOT_INTENTIC_OS_EXIT,
10
+ IC_OS_WIFI_PASSWORD_EXIT,
6
11
  icOsChannelArgs,
12
+ icOsDisplayListArgs,
13
+ icOsDisplaySetArgs,
14
+ icOsDriveEjectArgs,
15
+ icOsDriveImportArgs,
16
+ icOsDrivesListArgs,
17
+ icOsKeyboardLayoutsArgs,
18
+ icOsKeyboardSetArgs,
19
+ icOsKeyboardStatusArgs,
20
+ icOsNetworkStatusArgs,
21
+ icOsSoundOutputArgs,
22
+ icOsSoundOutputsArgs,
23
+ icOsSoundSetArgs,
24
+ icOsSoundStatusArgs,
25
+ icOsWifiConnectArgs,
26
+ icOsWifiForgetArgs,
27
+ icOsWifiListArgs,
28
+ icOsWifiRadioArgs,
29
+ IntenticOsDisplayListSchema,
30
+ IntenticOsDriveImportProgressSchema,
31
+ IntenticOsDriveImportSchema,
32
+ IntenticOsDrivesSchema,
33
+ IntenticOsKeyboardLayoutsSchema,
34
+ IntenticOsKeyboardStatusSchema,
35
+ IntenticOsNetworkStatusSchema,
36
+ IntenticOsSoundOutputsSchema,
37
+ IntenticOsSoundStatusSchema,
38
+ IntenticOsWifiListSchema,
7
39
  icOsRebootArgs,
8
40
  icOsRollbackArgs,
9
41
  icOsStatusArgs,
@@ -25,6 +57,7 @@ const GOLDEN = JSON.parse(readFileSync(join(packageRoot(import.meta.url), "golde
25
57
  readonly release: unknown;
26
58
  readonly status: readonly Readonly<Record<string, unknown>>[];
27
59
  readonly updateCheck: readonly Readonly<Record<string, unknown>>[];
60
+ readonly [settings: string]: unknown;
28
61
  };
29
62
 
30
63
  test("every status and update check in the golden reads back field for field", () => {
@@ -84,3 +117,118 @@ test("a channel ic does not have never reaches it, even past the type", () => {
84
117
  expect(() => icOsChannelArgs(channel as IntenticOsChannel), channel).toThrow();
85
118
  }
86
119
  });
120
+
121
+ /* THE MACHINE'S SETTINGS: the network, sound, screens, keyboard and drives, as ic's settings verbs print them. */
122
+
123
+ test("every settings answer in the golden reads back field for field", () => {
124
+ const readers = {
125
+ networkStatus: IntenticOsNetworkStatusSchema,
126
+ wifiList: IntenticOsWifiListSchema,
127
+ soundStatus: IntenticOsSoundStatusSchema,
128
+ soundOutputs: IntenticOsSoundOutputsSchema,
129
+ displayList: IntenticOsDisplayListSchema,
130
+ keyboardStatus: IntenticOsKeyboardStatusSchema,
131
+ keyboardLayouts: IntenticOsKeyboardLayoutsSchema,
132
+ drives: IntenticOsDrivesSchema,
133
+ driveImportProgress: IntenticOsDriveImportProgressSchema,
134
+ driveImport: IntenticOsDriveImportSchema,
135
+ } as const;
136
+ for (const [key, schema] of Object.entries(readers)) {
137
+ const examples = GOLDEN[key];
138
+ expect(Array.isArray(examples), key).toBe(true);
139
+ // SAFETY: checked to be an array just above.
140
+ for (const example of examples as unknown[]) {
141
+ const read: unknown = schema.parse(example);
142
+ expect(read, key).toEqual(example);
143
+ }
144
+ }
145
+ });
146
+
147
+ test("the status bar's two shapes are exactly what it reads, and nothing looser", () => {
148
+ expect(IntenticOsNetworkStatusSchema.safeParse({ connected: true, kind: "wifi", name: "x", signal: 101 }).success).toBe(false);
149
+ expect(IntenticOsNetworkStatusSchema.safeParse({ connected: true, kind: "cellular", name: "x", signal: null }).success).toBe(false);
150
+ expect(IntenticOsSoundStatusSchema.safeParse({ volume: -1, muted: false, output: null }).success).toBe(false);
151
+ expect(IntenticOsSoundStatusSchema.safeParse({ volume: 40.5, muted: false, output: null }).success).toBe(false);
152
+ });
153
+
154
+ test("the exit codes a caller tells apart are distinct", () => {
155
+ const codes = [IC_OS_NOT_INTENTIC_OS_EXIT, IC_OS_NO_SCREEN_EXIT, IC_OS_WIFI_PASSWORD_EXIT, IC_OS_DRIVE_NO_ROOM_EXIT];
156
+ expect(new Set(codes).size).toBe(codes.length);
157
+ expect(codes).not.toContain(0);
158
+ expect(codes).not.toContain(1);
159
+ });
160
+
161
+ test("the settings argv is ic's own verbs, and a password never rides on it", () => {
162
+ expect(icOsNetworkStatusArgs()).toEqual(["os", "network", "status", "--json"]);
163
+ expect(icOsWifiListArgs()).toEqual(["os", "network", "wifi", "list", "--json"]);
164
+ expect(icOsWifiConnectArgs("Home Fiber", false)).toEqual(["os", "network", "wifi", "connect", "--", "Home Fiber"]);
165
+ expect(icOsWifiConnectArgs("-guest", true)).toEqual(["os", "network", "wifi", "connect", "--password-stdin", "--", "-guest"]);
166
+ expect(icOsWifiForgetArgs("Office")).toEqual(["os", "network", "wifi", "forget", "--", "Office"]);
167
+ expect(icOsWifiRadioArgs(false)).toEqual(["os", "network", "wifi", "off"]);
168
+ expect(icOsWifiRadioArgs(true)).toEqual(["os", "network", "wifi", "on"]);
169
+ expect(icOsSoundStatusArgs()).toEqual(["os", "sound", "status", "--json"]);
170
+ expect(icOsSoundSetArgs({ volume: 40 })).toEqual(["os", "sound", "set", "--volume", "40", "--json"]);
171
+ expect(icOsSoundSetArgs({ volume: 39.6, muted: false })).toEqual(["os", "sound", "set", "--volume", "40", "--unmute", "--json"]);
172
+ expect(icOsSoundSetArgs({ muted: true })).toEqual(["os", "sound", "set", "--mute", "--json"]);
173
+ expect(icOsSoundOutputsArgs()).toEqual(["os", "sound", "outputs", "--json"]);
174
+ expect(icOsSoundOutputArgs(58)).toEqual(["os", "sound", "output", "58"]);
175
+ expect(icOsDisplayListArgs()).toEqual(["os", "display", "list", "--json"]);
176
+ expect(icOsDisplaySetArgs("DP-2", { mode: { width: 1920, height: 1080, refresh: 59.94 } })).toEqual([
177
+ "os",
178
+ "display",
179
+ "set",
180
+ "DP-2",
181
+ "--mode",
182
+ "1920x1080@59.94",
183
+ ]);
184
+ expect(icOsDisplaySetArgs("eDP-1", { scale: 1.25 })).toEqual(["os", "display", "set", "eDP-1", "--scale", "1.25"]);
185
+ expect(displayModeArg({ width: 2256, height: 1504, refresh: 59.9994 })).toBe("2256x1504@59.999");
186
+ expect(displayModeArg({ width: 3840, height: 2160, refresh: 60 })).toBe("3840x2160@60");
187
+ expect(icOsKeyboardStatusArgs()).toEqual(["os", "keyboard", "status", "--json"]);
188
+ expect(icOsKeyboardLayoutsArgs()).toEqual(["os", "keyboard", "layouts", "--json"]);
189
+ expect(icOsKeyboardSetArgs("de", "nodeadkeys")).toEqual(["os", "keyboard", "set", "de", "--variant", "nodeadkeys"]);
190
+ expect(icOsKeyboardSetArgs("us")).toEqual(["os", "keyboard", "set", "us"]);
191
+ expect(icOsDrivesListArgs()).toEqual(["os", "drives", "list", "--json"]);
192
+ expect(icOsDriveImportArgs("/dev/sda1", "work")).toEqual(["os", "drives", "import", "/dev/sda1", "--to", "work", "--json"]);
193
+ expect(icOsDriveImportArgs("/dev/sda1", "work", "Photos/2026")).toEqual([
194
+ "os",
195
+ "drives",
196
+ "import",
197
+ "/dev/sda1",
198
+ "--to",
199
+ "work",
200
+ "--path",
201
+ "Photos/2026",
202
+ "--json",
203
+ ]);
204
+ expect(icOsDriveEjectArgs("/dev/sdb1")).toEqual(["os", "drives", "eject", "/dev/sdb1"]);
205
+ });
206
+
207
+ test("a value that would reach ic as a flag, a path out, or nothing to change never does", () => {
208
+ const refused: (() => readonly string[])[] = [
209
+ () => icOsWifiConnectArgs("", false),
210
+ () => icOsWifiConnectArgs("x".repeat(33), false),
211
+ // 11 two-byte letters is 22 characters' worth of bytes past 32, though only 17 characters.
212
+ () => icOsWifiForgetArgs("ü".repeat(17)),
213
+ () => icOsSoundSetArgs({}),
214
+ () => icOsSoundSetArgs({ volume: 101 }),
215
+ () => icOsSoundOutputArgs(-1),
216
+ () => icOsDisplaySetArgs("--all", { scale: 1 }),
217
+ () => icOsDisplaySetArgs("DP-2", {}),
218
+ () => icOsDisplaySetArgs("DP-2", { scale: 9 }),
219
+ () => icOsKeyboardSetArgs("-de"),
220
+ () => icOsKeyboardSetArgs("de", "x y"),
221
+ () => icOsDriveImportArgs("/etc/passwd", "work"),
222
+ () => icOsDriveImportArgs("/dev/../etc", "work"),
223
+ () => icOsDriveImportArgs("/dev/sda1", "--rm"),
224
+ () => icOsDriveImportArgs("/dev/sda1", "a..b"),
225
+ () => icOsDriveImportArgs("/dev/sda1", "work", "../history"),
226
+ () => icOsDriveImportArgs("/dev/sda1", "work", "/etc"),
227
+ () => icOsDriveImportArgs("/dev/sda1", "work", "a//b"),
228
+ () => icOsDriveEjectArgs("sda"),
229
+ ];
230
+ for (const [index, call] of refused.entries()) {
231
+ expect(call, String(index)).toThrow();
232
+ }
233
+ expect(icOsWifiForgetArgs("ü".repeat(16))).toHaveLength(6);
234
+ });
@@ -128,3 +128,262 @@ export const icOsRebootArgs = (): string[] => ["os", "reboot"];
128
128
  // Checked here too, not only by a caller's schema: a channel is a positional, and one beginning with a hyphen would
129
129
  // reach ic as a flag.
130
130
  export const icOsChannelArgs = (channel: IntenticOsChannel): string[] => ["os", "channel", IntenticOsChannelSchema.parse(channel)];
131
+
132
+ // THE MACHINE'S OWN SETTINGS (2026-10-16): the network, the sound, the screen, the keyboard and the drives a person plugs
133
+ // in, each a verb of `ic os` (_os/README.md, "Settings"). The desktop app's This computer cards, its status bar and the
134
+ // machine agent's tools all read these shapes, so a person and the agent in the machine's own sandbox change the same
135
+ // things the same way ("connect to the guest Wi-Fi"). ic reads NetworkManager, WirePlumber, sway and udisks for them:
136
+ // nothing that calls ic speaks to those.
137
+
138
+ /** Exit code of the screen verbs (display, keyboard) on an edition with no screen (the server edition). */
139
+ export const IC_OS_NO_SCREEN_EXIT = 4;
140
+
141
+ /** Exit code of `ic os network wifi connect` when the network needs a password it was not given, or refused the one it
142
+ * was: ask the person for it and try again with `--password-stdin`. */
143
+ export const IC_OS_WIFI_PASSWORD_EXIT = 5;
144
+
145
+ /** Exit code of `ic os drives import` when what is on the drive would not fit in the sandbox: nothing was copied. */
146
+ export const IC_OS_DRIVE_NO_ROOM_EXIT = 6;
147
+
148
+ // `ic os network status --json`: how this machine is online right now. `name` is the Wi-Fi network's name or the wired
149
+ // connection's, `signal` the Wi-Fi strength 0-100 (null on a wire and offline). The status bar reads exactly this.
150
+ export const IntenticOsNetworkKindSchema = z.enum(["wifi", "ethernet", "none"]);
151
+ export type IntenticOsNetworkKind = z.infer<typeof IntenticOsNetworkKindSchema>;
152
+
153
+ const percent = z.number().int().min(0).max(100);
154
+
155
+ export const IntenticOsNetworkStatusSchema = z.object({
156
+ connected: z.boolean(),
157
+ kind: IntenticOsNetworkKindSchema,
158
+ name: z.string().nullable(),
159
+ signal: percent.nullable(),
160
+ });
161
+ export type IntenticOsNetworkStatus = z.infer<typeof IntenticOsNetworkStatusSchema>;
162
+
163
+ // One Wi-Fi network in reach, once however many access points broadcast it: the strongest signal, `secured` when it
164
+ // asks for a password, `known` when this machine has joined it before (it rejoins without asking), `active` when it is
165
+ // the one in use.
166
+ export const IntenticOsWifiNetworkSchema = z.object({
167
+ ssid: z.string(),
168
+ signal: percent,
169
+ secured: z.boolean(),
170
+ known: z.boolean(),
171
+ active: z.boolean(),
172
+ });
173
+ export type IntenticOsWifiNetwork = z.infer<typeof IntenticOsWifiNetworkSchema>;
174
+
175
+ // `ic os network wifi list --json`: `adapter` false on a machine with no Wi-Fi, `enabled` false while Wi-Fi is turned
176
+ // off (then `networks` is empty), the networks strongest first.
177
+ export const IntenticOsWifiListSchema = z.object({
178
+ adapter: z.boolean(),
179
+ enabled: z.boolean(),
180
+ networks: z.array(IntenticOsWifiNetworkSchema),
181
+ });
182
+ export type IntenticOsWifiList = z.infer<typeof IntenticOsWifiListSchema>;
183
+
184
+ // `ic os sound status --json`: the volume of what plays now, 0-100, whether it is muted, and the speaker or headphones it
185
+ // plays on (null when this machine has none). The status bar reads exactly this.
186
+ export const IntenticOsSoundStatusSchema = z.object({
187
+ volume: percent,
188
+ muted: z.boolean(),
189
+ output: z.string().nullable(),
190
+ });
191
+ export type IntenticOsSoundStatus = z.infer<typeof IntenticOsSoundStatusSchema>;
192
+
193
+ // `ic os sound outputs --json`: every speaker, headphone and screen that plays sound, `default` on the one in use.
194
+ // `id` is what `ic os sound output <id>` takes; it can change when a device is plugged in again.
195
+ export const IntenticOsSoundOutputSchema = z.object({
196
+ id: z.number().int().nonnegative(),
197
+ name: z.string(),
198
+ default: z.boolean(),
199
+ });
200
+ export type IntenticOsSoundOutput = z.infer<typeof IntenticOsSoundOutputSchema>;
201
+ export const IntenticOsSoundOutputsSchema = z.object({ outputs: z.array(IntenticOsSoundOutputSchema) });
202
+ export type IntenticOsSoundOutputs = z.infer<typeof IntenticOsSoundOutputsSchema>;
203
+
204
+ // A screen's mode: pixels across and down, and how many times a second it redraws (Hz, to three decimals).
205
+ export const IntenticOsDisplayModeSchema = z.object({
206
+ width: z.number().int().positive(),
207
+ height: z.number().int().positive(),
208
+ refresh: z.number().nonnegative(),
209
+ });
210
+ export type IntenticOsDisplayMode = z.infer<typeof IntenticOsDisplayModeSchema>;
211
+
212
+ // One screen as `ic os display list --json` says it: `name` is the connector (`HDMI-A-1`, `eDP-1`) every other verb
213
+ // names it by, `make` and `model` what the screen says it is (null where it does not), `current` the mode it shows now
214
+ // (null while it is off), `modes` every mode it offers, `scale` how much bigger everything is drawn (null while off).
215
+ export const IntenticOsDisplaySchema = z.object({
216
+ name: z.string(),
217
+ make: z.string().nullable(),
218
+ model: z.string().nullable(),
219
+ enabled: z.boolean(),
220
+ current: IntenticOsDisplayModeSchema.nullable(),
221
+ modes: z.array(IntenticOsDisplayModeSchema),
222
+ scale: z.number().positive().nullable(),
223
+ });
224
+ export type IntenticOsDisplay = z.infer<typeof IntenticOsDisplaySchema>;
225
+ export const IntenticOsDisplayListSchema = z.object({ outputs: z.array(IntenticOsDisplaySchema) });
226
+ export type IntenticOsDisplayList = z.infer<typeof IntenticOsDisplayListSchema>;
227
+
228
+ // `ic os keyboard status --json`: the layout in use as xkb names it (`us`, `de`), its variant (`nodeadkeys`, null for
229
+ // the layout's own), and its name for a person ("German (no dead keys)"), null when xkb has none for it.
230
+ export const IntenticOsKeyboardStatusSchema = z.object({
231
+ layout: z.string(),
232
+ variant: z.string().nullable(),
233
+ name: z.string().nullable(),
234
+ });
235
+ export type IntenticOsKeyboardStatus = z.infer<typeof IntenticOsKeyboardStatusSchema>;
236
+
237
+ export const IntenticOsKeyboardVariantSchema = z.object({ variant: z.string(), name: z.string() });
238
+ export const IntenticOsKeyboardLayoutSchema = z.object({
239
+ layout: z.string(),
240
+ name: z.string(),
241
+ variants: z.array(IntenticOsKeyboardVariantSchema),
242
+ });
243
+ export type IntenticOsKeyboardLayout = z.infer<typeof IntenticOsKeyboardLayoutSchema>;
244
+ // `ic os keyboard layouts --json`: every layout xkb knows, in its own order, each with its variants.
245
+ export const IntenticOsKeyboardLayoutsSchema = z.object({ layouts: z.array(IntenticOsKeyboardLayoutSchema) });
246
+ export type IntenticOsKeyboardLayouts = z.infer<typeof IntenticOsKeyboardLayoutsSchema>;
247
+
248
+ // What kind of removable drive: a USB stick or disk, or a memory card in a card reader.
249
+ export const IntenticOsDriveKindSchema = z.enum(["usb", "card", "other"]);
250
+ export type IntenticOsDriveKind = z.infer<typeof IntenticOsDriveKindSchema>;
251
+
252
+ // One removable drive's filesystem (a partition, or the whole stick when it has none) as `ic os drives list --json`
253
+ // says it: `device` is what the other drive verbs take, `label` the name it was given (null for none), `size` in bytes,
254
+ // `filesystem` as Linux names it (`vfat`, `exfat`, `ntfs`, `ext4`), `mountedAt` where it is open (null while it is
255
+ // not), `model` what the drive says it is. The machine's own disks are never listed.
256
+ export const IntenticOsDriveSchema = z.object({
257
+ device: z.string(),
258
+ label: z.string().nullable(),
259
+ size: z.number().int().nonnegative(),
260
+ filesystem: z.string().nullable(),
261
+ mountedAt: z.string().nullable(),
262
+ readOnly: z.boolean(),
263
+ model: z.string().nullable(),
264
+ kind: IntenticOsDriveKindSchema,
265
+ });
266
+ export type IntenticOsDrive = z.infer<typeof IntenticOsDriveSchema>;
267
+ export const IntenticOsDrivesSchema = z.object({ drives: z.array(IntenticOsDriveSchema) });
268
+ export type IntenticOsDrives = z.infer<typeof IntenticOsDrivesSchema>;
269
+
270
+ // `ic os drives import … --json` prints one of these every few seconds while it copies, then the result last.
271
+ export const IntenticOsDriveImportProgressSchema = z.object({
272
+ progress: z.object({ bytes: z.number().int().nonnegative(), total: z.number().int().nonnegative() }),
273
+ });
274
+ export type IntenticOsDriveImportProgress = z.infer<typeof IntenticOsDriveImportProgressSchema>;
275
+
276
+ // What was copied: from which drive, into which sandbox, the folder inside it (`/work/Imports/<label>-<date>`), and how
277
+ // many bytes in how many files.
278
+ export const IntenticOsDriveImportSchema = z.object({
279
+ device: z.string(),
280
+ sandbox: z.string(),
281
+ path: z.string(),
282
+ bytes: z.number().int().nonnegative(),
283
+ files: z.number().int().nonnegative(),
284
+ });
285
+ export type IntenticOsDriveImport = z.infer<typeof IntenticOsDriveImportSchema>;
286
+
287
+ // The pieces a caller hands ic as positionals and flag values, checked here before they reach its command line: none may
288
+ // begin with a hyphen (it would be a flag), and none is a path out of where it belongs. An SSID is anything a network
289
+ // broadcasts, so it goes after `--` instead.
290
+ const OUTPUT_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/;
291
+ const XKB_NAME = /^[A-Za-z0-9][A-Za-z0-9_+-]{0,63}$/;
292
+ const BLOCK_DEVICE = /^\/dev\/[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
293
+ const SANDBOX_SLUG = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
294
+
295
+ // An SSID is at most 32 bytes, in whatever characters the network broadcasts.
296
+ export const IntenticOsSsidSchema = z
297
+ .string()
298
+ .min(1)
299
+ .refine((ssid) => new TextEncoder().encode(ssid).length <= 32, "a Wi-Fi network's name, at most 32 bytes");
300
+ export const IntenticOsDisplayNameSchema = z.string().regex(OUTPUT_NAME, "a screen's connector name, like HDMI-A-1");
301
+ export const IntenticOsXkbNameSchema = z.string().regex(XKB_NAME, "an xkb layout or variant name, like de or nodeadkeys");
302
+ export const IntenticOsBlockDeviceSchema = z.string().regex(BLOCK_DEVICE, "a drive's device, like /dev/sdb1");
303
+ export const IntenticOsSandboxSlugSchema = z.string().regex(SANDBOX_SLUG, "a sandbox's slug").refine((slug) => !slug.includes(".."), "a sandbox's slug");
304
+ // A folder inside the sandbox's /work: relative, no `..`, no leading slash or hyphen.
305
+ export const IntenticOsImportPathSchema = z
306
+ .string()
307
+ .min(1)
308
+ .max(255)
309
+ .refine(
310
+ (path) => !path.startsWith("/") && !path.startsWith("-") && path.split("/").every((part) => part !== ".." && part !== "." && part !== ""),
311
+ "a folder inside the sandbox's /work, like Photos/2026",
312
+ );
313
+ export const IntenticOsScaleSchema = z.number().min(0.5).max(4);
314
+
315
+ /** A mode the way `ic os display set --mode` takes it: `1920x1080@60`, the rate to three decimals at most. */
316
+ export const displayModeArg = (mode: IntenticOsDisplayMode): string => `${mode.width}x${mode.height}@${Number(mode.refresh.toFixed(3))}`;
317
+
318
+ export const icOsNetworkStatusArgs = (): string[] => ["os", "network", "status", "--json"];
319
+ export const icOsWifiListArgs = (): string[] => ["os", "network", "wifi", "list", "--json"];
320
+ // The password never: it goes on ic's stdin with `--password-stdin`, since a command line is readable by anyone on the
321
+ // machine (`ps`). Without one, ic joins a known network or one that asks for none, and exits IC_OS_WIFI_PASSWORD_EXIT
322
+ // for one that needs a password.
323
+ export const icOsWifiConnectArgs = (ssid: string, withPassword: boolean): string[] => [
324
+ "os",
325
+ "network",
326
+ "wifi",
327
+ "connect",
328
+ ...(withPassword ? ["--password-stdin"] : []),
329
+ "--",
330
+ IntenticOsSsidSchema.parse(ssid),
331
+ ];
332
+ export const icOsWifiForgetArgs = (ssid: string): string[] => ["os", "network", "wifi", "forget", "--", IntenticOsSsidSchema.parse(ssid)];
333
+ export const icOsWifiRadioArgs = (on: boolean): string[] => ["os", "network", "wifi", on ? "on" : "off"];
334
+
335
+ export const icOsSoundStatusArgs = (): string[] => ["os", "sound", "status", "--json"];
336
+ // At least one of the two: a volume 0-100, and muted or not. Answers with the status after (`--json`).
337
+ export const icOsSoundSetArgs = (change: { readonly volume?: number; readonly muted?: boolean }): string[] => {
338
+ const args = ["os", "sound", "set"];
339
+ if (change.volume !== undefined) {
340
+ args.push("--volume", String(percent.parse(Math.round(change.volume))));
341
+ }
342
+ if (change.muted !== undefined) {
343
+ args.push(change.muted ? "--mute" : "--unmute");
344
+ }
345
+ if (args.length === 3) {
346
+ throw new Error("a sound change names a volume, muted, or both");
347
+ }
348
+ return [...args, "--json"];
349
+ };
350
+ export const icOsSoundOutputsArgs = (): string[] => ["os", "sound", "outputs", "--json"];
351
+ export const icOsSoundOutputArgs = (id: number): string[] => ["os", "sound", "output", String(z.number().int().nonnegative().parse(id))];
352
+
353
+ export const icOsDisplayListArgs = (): string[] => ["os", "display", "list", "--json"];
354
+ export const icOsDisplaySetArgs = (output: string, change: { readonly mode?: IntenticOsDisplayMode; readonly scale?: number }): string[] => {
355
+ const args = ["os", "display", "set", IntenticOsDisplayNameSchema.parse(output)];
356
+ if (change.mode !== undefined) {
357
+ args.push("--mode", displayModeArg(IntenticOsDisplayModeSchema.parse(change.mode)));
358
+ }
359
+ if (change.scale !== undefined) {
360
+ args.push("--scale", String(IntenticOsScaleSchema.parse(change.scale)));
361
+ }
362
+ if (args.length === 4) {
363
+ throw new Error("a display change names a mode, a scale, or both");
364
+ }
365
+ return args;
366
+ };
367
+
368
+ export const icOsKeyboardStatusArgs = (): string[] => ["os", "keyboard", "status", "--json"];
369
+ export const icOsKeyboardLayoutsArgs = (): string[] => ["os", "keyboard", "layouts", "--json"];
370
+ export const icOsKeyboardSetArgs = (layout: string, variant?: string): string[] => [
371
+ "os",
372
+ "keyboard",
373
+ "set",
374
+ IntenticOsXkbNameSchema.parse(layout),
375
+ ...(variant === undefined ? [] : ["--variant", IntenticOsXkbNameSchema.parse(variant)]),
376
+ ];
377
+
378
+ export const icOsDrivesListArgs = (): string[] => ["os", "drives", "list", "--json"];
379
+ export const icOsDriveImportArgs = (device: string, sandbox: string, path?: string): string[] => [
380
+ "os",
381
+ "drives",
382
+ "import",
383
+ IntenticOsBlockDeviceSchema.parse(device),
384
+ "--to",
385
+ IntenticOsSandboxSlugSchema.parse(sandbox),
386
+ ...(path === undefined ? [] : ["--path", IntenticOsImportPathSchema.parse(path)]),
387
+ "--json",
388
+ ];
389
+ export const icOsDriveEjectArgs = (device: string): string[] => ["os", "drives", "eject", IntenticOsBlockDeviceSchema.parse(device)];
@@ -62,6 +62,9 @@ export const PhoneFactsSchema = z.object({
62
62
  sdk: z.number().int(),
63
63
  // "direct" (downloaded from intentic.dev, can touch and type) or "play" (the store build, which cannot).
64
64
  build: z.string(),
65
+ // What kind of Android device answered: "watch" for Wear OS, which has no folders and no notification access to
66
+ // give; absent from an app older than this field, which is a phone. A string, so a newer app's "tablet" still reads.
67
+ form: z.string().optional(),
65
68
  // The pause tile on the phone; true means every tool refuses and says the person paused it.
66
69
  paused: z.boolean(),
67
70
  // What the person has switched on in Android's own settings for this app.
@@ -208,8 +208,11 @@ export type DaemonSession = z.infer<typeof DaemonSessionSchema>;
208
208
  // How a session's identity was proven. A session carries the set it was minted from; the require-passkey policy reads
209
209
  // it on every request, so a session minted before the switch stops working on its next call. `email` is the platform's
210
210
  // signed word that the person controls the address (policy/identity-assertion.ts), accepted only where the owner opted
211
- // that person in, and on hosted machines.
212
- export const ProofMethodSchema = z.enum(["google", "ticket", "passkey", "recovery", "email"]);
211
+ // that person in, and on hosted machines. `host` is the machine that runs the sandbox: a session `ic sandbox session`
212
+ // minted through `docker exec` for the bound owner, so the desktop app and Intentic OS reach their own sandboxes with no
213
+ // network (the daemon's auth/host/host-session.ts). It is never a sign-in a browser can offer, so no daemon lists it in
214
+ // `signIn.proofs`.
215
+ export const ProofMethodSchema = z.enum(["google", "ticket", "passkey", "recovery", "email", "host"]);
213
216
  export type ProofMethod = z.infer<typeof ProofMethodSchema>;
214
217
 
215
218
  // The `reason` a daemon's refusal of an identity assertion carries beside its `error`, so the editor can say what to do:
@@ -172,6 +172,9 @@ export const HISTORY_STATE_FILES: readonly StateFile[] = [
172
172
 
173
173
  // Signs every browser session cookie; carrying it would let the holder mint sessions against the target.
174
174
  { path: "session-secret", portability: "identity", note: "Sign in again, the target signs its own sessions." },
175
+ // The platform's id for this sandbox's row, as its announce last said (system/boot/platform-row.ts): the target is
176
+ // its own row, and learns its own id at its first announce.
177
+ { path: "platform-row.json", portability: "identity", note: "The target learns its own platform id when it first announces." },
175
178
  // Who owns this sandbox, who else may reach it and at what tier, and the hashed control tokens programs present:
176
179
  // the daemon's to decide, so kept off the workspace every turn writes (auth/members/identity-off-workspace.ts moved
177
180
  // them from `.intentic/identity/` on 2026-10-06). An access list that traveled would let a source sandbox hand