omp-mobile 0.1.1 → 0.2.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.
package/README.md CHANGED
@@ -14,6 +14,8 @@
14
14
  OMP Mobile is a native phone app for [oh-my-pi](https://github.com/can1357/oh-my-pi) (OMP), with a small daemon on each computer. Start, follow and steer OMP sessions from the phone: transcript, tool calls, approvals, and the sessions you started in a terminal.
15
15
 
16
16
  - **Native app:** The app draws the transcript, tool cards, thinking, Markdown and composer itself. No WebView, no web page.
17
+ - **The whole transcript:** images the user sent and images tools produce (screenshots, opened files), edit diffs with their intent, tool results, Markdown tables, OMP's todo list, context usage and queued messages.
18
+ - **Model switching:** tap the model in the composer to pick from OMP's catalog — grouped by provider and searchable — and set its thinking level. The model chip follows the session when it changes.
17
19
  - **Approvals on the phone:** OMP's own tool approvals and questions arrive as a sheet, and the turn waits for your answer.
18
20
  - **Terminal sessions:** Sessions you started in a terminal on the computer are listed too and continue on the phone. One still open in a terminal waits until that terminal exits.
19
21
  - **Notifications:** Android notifications for approvals, questions, and finished turns while the app is in the background.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale.",
5
5
  "keywords": [
6
6
  "mobile",
@@ -35,6 +35,36 @@ const IGNORED_UI = new Set(["setStatus", "setWidget", "setTitle", "set_editor_te
35
35
  * @property {boolean} [autoApprove]
36
36
  */
37
37
 
38
+ /**
39
+ * One entry of OMP's model catalog, cut to what a phone lists: provider, name,
40
+ * context window and input kinds. The full object carries endpoints, cost and
41
+ * compatibility fields the app never shows.
42
+ * @param {Record<string, unknown>} model
43
+ */
44
+ export function compactModel(model) {
45
+ const thinking = model.thinking;
46
+ const cost = model.cost;
47
+ return {
48
+ provider: typeof model.provider === "string" ? model.provider : "",
49
+ id: typeof model.id === "string" ? model.id : "",
50
+ name: typeof model.name === "string" ? model.name : typeof model.id === "string" ? model.id : "",
51
+ reasoning: model.reasoning === true,
52
+ input: Array.isArray(model.input) ? model.input.filter((kind) => typeof kind === "string") : [],
53
+ contextWindow: typeof model.contextWindow === "number" ? model.contextWindow : undefined,
54
+ cost:
55
+ cost !== null && typeof cost === "object"
56
+ ? {
57
+ input: typeof cost.input === "number" ? cost.input : undefined,
58
+ output: typeof cost.output === "number" ? cost.output : undefined,
59
+ }
60
+ : undefined,
61
+ efforts:
62
+ thinking !== null && typeof thinking === "object" && Array.isArray(thinking.efforts)
63
+ ? thinking.efforts.filter((level) => typeof level === "string")
64
+ : undefined,
65
+ };
66
+ }
67
+
38
68
  export class RpcSession extends EventEmitter {
39
69
  /** @param {RpcSessionOptions} options */
40
70
  constructor(options) {
@@ -228,6 +258,41 @@ export class RpcSession extends EventEmitter {
228
258
  return response.data ?? { messages: [] };
229
259
  }
230
260
 
261
+ /**
262
+ * The models this session can switch to, compacted for a phone list.
263
+ * OMP waits out background discovery first, so this can take a few seconds.
264
+ * @returns {Promise<ReturnType<typeof compactModel>[]>}
265
+ */
266
+ async availableModels() {
267
+ const response = await this.request("get_available_models");
268
+ const models = response.data?.models;
269
+ return Array.isArray(models) ? models.map(compactModel) : [];
270
+ }
271
+
272
+ /**
273
+ * Switch this session to another model. Returns the state afterwards: the
274
+ * model's thinking efforts decide which levels the phone may offer.
275
+ * @param {string} provider
276
+ * @param {string} modelId
277
+ */
278
+ async setModel(provider, modelId) {
279
+ await this.request("set_model", { provider, modelId });
280
+ return this.getState();
281
+ }
282
+
283
+ /** @returns {Promise<{levels: string[]}>} */
284
+ async availableThinkingLevels() {
285
+ const response = await this.request("get_available_thinking_levels");
286
+ const levels = response.data?.levels;
287
+ return { levels: Array.isArray(levels) ? levels.filter((level) => typeof level === "string") : [] };
288
+ }
289
+
290
+ /** @param {string} level */
291
+ async setThinkingLevel(level) {
292
+ await this.request("set_thinking_level", { level });
293
+ return this.getState();
294
+ }
295
+
231
296
  /** @param {string} text */
232
297
  async prompt(text) {
233
298
  return this.request("prompt", { message: text });
package/src/server.mjs CHANGED
@@ -30,6 +30,20 @@ const DEFAULT_OMP_HOME = join(homedir(), ".omp");
30
30
  const MAX_BODY = 256 * 1024;
31
31
  /** Terminal sessions listed next to the daemon's own, newest first. */
32
32
  const OMP_SESSION_LIMIT = 50;
33
+ /**
34
+ * Session events after which `get_state` differs from what the phone holds:
35
+ * the model or thinking level changed, or the todo/queue/goal panels moved.
36
+ */
37
+ const STATE_EVENTS = new Set([
38
+ "model_changed",
39
+ "thinking_level_changed",
40
+ "queue_update",
41
+ "todo_reminder",
42
+ "todo_auto_clear",
43
+ "goal_updated",
44
+ ]);
45
+ /** OMP's content-addressed image store under `<omp home>/agent/blobs`. */
46
+ const BLOB_NAME = /^[0-9a-f]{64}$/;
33
47
 
34
48
  /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean}} SessionSummary */
35
49
 
@@ -225,6 +239,39 @@ function sendJson(res, status, body) {
225
239
  res.end(data);
226
240
  }
227
241
 
242
+ /** What a stored blob is, from its first bytes: OMP keeps the extension off the hash name. */
243
+ export function blobType(bytes) {
244
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") {
245
+ return "image/webp";
246
+ }
247
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
248
+ return "image/png";
249
+ }
250
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
251
+ if (bytes.length >= 4 && bytes.toString("ascii", 0, 4) === "GIF8") return "image/gif";
252
+ return "application/octet-stream";
253
+ }
254
+
255
+ /** A stored image: `GET /blobs/<sha256>[.ext]`, authenticated like every other route. */
256
+ function sendBlob(res, ompHome, name) {
257
+ if (!BLOB_NAME.test(name)) {
258
+ sendJson(res, 400, { error: "bad blob name" });
259
+ return;
260
+ }
261
+ const path = join(ompHome, "agent", "blobs", name);
262
+ if (!existsSync(path) || !statSync(path).isFile()) {
263
+ sendJson(res, 404, { error: "no such blob" });
264
+ return;
265
+ }
266
+ const bytes = readFileSync(path);
267
+ res.writeHead(200, {
268
+ "content-type": blobType(bytes),
269
+ "content-length": bytes.length,
270
+ "cache-control": "private, max-age=31536000, immutable",
271
+ });
272
+ res.end(bytes);
273
+ }
274
+
228
275
  function readBody(req) {
229
276
  return new Promise((resolveBody, reject) => {
230
277
  let size = 0;
@@ -359,6 +406,9 @@ class SessionEntry {
359
406
  this.onChange?.(this);
360
407
  }
361
408
  }
409
+ if (STATE_EVENTS.has(event.type)) void this.refreshState();
410
+ // A `todo` tool call rewrites the list before any reminder fires.
411
+ if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
362
412
  if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
363
413
  this.broadcast({ type: "event", event });
364
414
  });
@@ -488,6 +538,11 @@ class Daemon {
488
538
  return;
489
539
  }
490
540
  this.noteClient(req);
541
+ const blob = /^\/blobs\/([0-9a-f]{64})(?:\.[a-z0-9]+)?$/.exec(url.pathname);
542
+ if (blob !== null && req.method === "GET") {
543
+ sendBlob(res, this.options.ompHome, blob[1]);
544
+ return;
545
+ }
491
546
  if (url.pathname === "/status" && req.method === "GET") {
492
547
  sendJson(res, 200, {
493
548
  pid: process.pid,
@@ -730,6 +785,39 @@ class Daemon {
730
785
  case "refresh":
731
786
  await entry.refreshState();
732
787
  return;
788
+ case "models": {
789
+ const models = await entry.rpc.availableModels();
790
+ ws.send(JSON.stringify({ type: "models", models }));
791
+ return;
792
+ }
793
+ case "set_model": {
794
+ if (typeof message.provider !== "string" || typeof message.modelId !== "string") {
795
+ throw new Error("set_model needs provider and modelId");
796
+ }
797
+ const state = await entry.rpc.setModel(message.provider, message.modelId);
798
+ entry.state = state;
799
+ entry.updatedAt = Date.now();
800
+ entry.onChange?.(entry);
801
+ entry.broadcast({ type: "state", state });
802
+ ws.send(JSON.stringify({ type: "accepted", command: "set_model" }));
803
+ return;
804
+ }
805
+ case "thinking_levels": {
806
+ const { levels } = await entry.rpc.availableThinkingLevels();
807
+ ws.send(
808
+ JSON.stringify({ type: "thinking_levels", levels, current: entry.state?.thinkingLevel ?? null }),
809
+ );
810
+ return;
811
+ }
812
+ case "set_thinking_level": {
813
+ if (typeof message.level !== "string" || message.level === "") throw new Error("set_thinking_level needs a level");
814
+ const state = await entry.rpc.setThinkingLevel(message.level);
815
+ entry.state = state;
816
+ entry.updatedAt = Date.now();
817
+ entry.broadcast({ type: "state", state });
818
+ ws.send(JSON.stringify({ type: "accepted", command: "set_thinking_level" }));
819
+ return;
820
+ }
733
821
  default:
734
822
  throw new Error(`unknown command ${String(message.type)}`);
735
823
  }