omp-mobile 0.5.1 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "omp-mobile",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "The computer side of OMP Mobile: runs oh-my-pi sessions and serves them to the phone app over Tailscale or an end-to-end encrypted relay.",
5
5
  "keywords": [
6
6
  "mobile",
package/src/attention.mjs CHANGED
@@ -31,6 +31,8 @@ import { hostname } from "node:os";
31
31
  export const HEARTBEAT_MS = 25_000;
32
32
  /** OMP's tool approval is a `select` whose title starts with this. */
33
33
  export const TOOL_APPROVAL_PREFIX = "Allow tool:";
34
+ /** The labels OMP starts approval details with (`Command: `, `Path: `), from its `tools/approval.ts` and each tool. */
35
+ const DETAIL_LABEL = /^(?:Command|Path|Paths|Pattern|Language|Code|Content|URL|Reason|Origin):\s*/;
34
36
  const ASKING = new Set(["select", "confirm", "input", "editor"]);
35
37
  const TEXT_MAX = 280;
36
38
 
@@ -127,7 +129,10 @@ export class AttentionTracker {
127
129
  const approval = isToolApproval(request);
128
130
  const title = typeof request.title === "string" ? request.title : "";
129
131
  // OMP's tool approval title is `Allow tool: bash\nCommand: echo hi`: the tool, then its details.
130
- const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title).split("\n").map((line) => line.trim());
132
+ // A notification reads in the phone's language, so OMP's English labels (`Command:`) are left out.
133
+ const lines = (approval ? title.slice(TOOL_APPROVAL_PREFIX.length) : title)
134
+ .split("\n")
135
+ .map((line, index) => (approval && index > 0 ? line.replace(DETAIL_LABEL, "") : line).trim());
131
136
  const text = [...lines, request.message].filter(Boolean).join(" · ");
132
137
  state.open.set(request.id, emit(approval ? "approval" : "question", request.id, text));
133
138
  break;
@@ -400,11 +400,14 @@ export class RpcSession extends EventEmitter {
400
400
  }
401
401
 
402
402
  /**
403
+ * Send a prompt. While a turn runs it is queued as a steer, as Enter in the
404
+ * terminal does: OMP delivers it at the next step boundary (the queue chips
405
+ * show it meanwhile). Idle, it starts a turn as before.
403
406
  * @param {string} text
404
407
  * @param {{type: "image", data: string, mimeType: string}[]} [images] OMP `ImageContent` parts sent with the text.
405
408
  */
406
409
  async prompt(text, images = []) {
407
- return this.request("prompt", images.length > 0 ? { message: text, images } : { message: text });
410
+ return this.request("prompt", { message: text, ...(images.length > 0 ? { images } : {}), streamingBehavior: "steer" });
408
411
  }
409
412
 
410
413
  async abort() {
package/src/server.mjs CHANGED
@@ -119,7 +119,8 @@ const OPEN_FAILED_CLOSE_CODE = 4000;
119
119
  const BLOB_NAME = /^[0-9a-f]{64}$/;
120
120
 
121
121
  /** @typedef {{pid: number, busy: boolean, terminal: boolean}} SessionOwner */
122
- /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number}} SessionSummary */
122
+ /** @typedef {{id: string, method: string, title: string, message?: string, options?: string[]}} PendingRequest */
123
+ /** @typedef {{id: string, title: string, cwd: string, status: string, model?: string, createdAt: number, updatedAt: number, sessionFile?: string, origin: "daemon" | "omp", openElsewhere?: boolean, owner?: SessionOwner, waiting?: number, request?: PendingRequest}} SessionSummary */
123
124
 
124
125
  /** An error with the HTTP status it answers, and any detail its JSON answer carries. */
125
126
  function httpError(status, message, detail = {}) {
@@ -132,6 +133,29 @@ function errorBody(error) {
132
133
  return { error: error instanceof Error ? error.message : String(error), ...detail };
133
134
  }
134
135
 
136
+ /**
137
+ * The part of an open extension request a session list shows: enough to answer
138
+ * a tool approval or a short choice from the home, nothing for a text prompt.
139
+ * @param {any} request
140
+ * @returns {PendingRequest | undefined}
141
+ */
142
+ function pendingOf(request) {
143
+ if (request === null || typeof request !== "object" || typeof request.id !== "string") return undefined;
144
+ const pending = { id: request.id, method: String(request.method), title: typeof request.title === "string" ? request.title : "" };
145
+ if (typeof request.message === "string") pending.message = request.message;
146
+ if (Array.isArray(request.options)) pending.options = request.options.filter((option) => typeof option === "string");
147
+ return pending;
148
+ }
149
+
150
+ /** The answer fields a phone sends, over the session socket or `POST /sessions/:id/answer`. */
151
+ function answerOf(message) {
152
+ const answer = {};
153
+ if (typeof message.value === "string") answer.value = message.value;
154
+ if (typeof message.confirmed === "boolean") answer.confirmed = message.confirmed;
155
+ if (message.cancelled === true) answer.cancelled = true;
156
+ return answer;
157
+ }
158
+
135
159
  function parseArgs(argv) {
136
160
  const options = {
137
161
  host: "127.0.0.1",
@@ -631,6 +655,8 @@ class SessionEntry {
631
655
  origin: "daemon",
632
656
  // OMP questions and tool approvals still waiting for an answer: the session needs the user.
633
657
  waiting: this.openRequests().length,
658
+ // The oldest of them, so a list can answer it without opening the session.
659
+ request: pendingOf(this.openRequests()[0]),
634
660
  };
635
661
  }
636
662
 
@@ -683,6 +709,8 @@ class SessionEntry {
683
709
  if (STATE_EVENTS.has(event.type)) void this.refreshState();
684
710
  // A `todo` tool call rewrites the list before any reminder fires.
685
711
  if (event.type === "tool_execution_end" && event.toolName === "todo") void this.refreshState();
712
+ // A run starting is what makes a session "running" in the list; OMP's state read would lag behind.
713
+ if (event.type === "agent_start" && this.state !== null && typeof this.state === "object") this.state = { ...this.state, isStreaming: true };
686
714
  if (event.type === "turn_end" || event.type === "agent_end") void this.refreshState();
687
715
  this.broadcast({ type: "event", event });
688
716
  });
@@ -922,6 +950,18 @@ class Daemon {
922
950
  sendJson(res, 200, this.transcript(decodeURIComponent(transcript[1]), deltaQuery(url.searchParams)));
923
951
  return;
924
952
  }
953
+ const answer = /^\/sessions\/([^/]+)\/answer$/.exec(url.pathname);
954
+ if (answer !== null && req.method === "POST") {
955
+ const body = await readBody(req);
956
+ const live = this.sessions.get(decodeURIComponent(answer[1]));
957
+ if (live === undefined) throw httpError(404, "session is not running");
958
+ if (typeof body.id !== "string" || !live.openRequests().some((request) => request.id === body.id)) {
959
+ throw httpError(409, "that question was already answered");
960
+ }
961
+ live.rpc.respondExtension(body.id, answerOf(body));
962
+ sendJson(res, 200, { ok: true });
963
+ return;
964
+ }
925
965
  const match = /^\/sessions\/([^/]+)$/.exec(url.pathname);
926
966
  if (match !== null) {
927
967
  const id = decodeURIComponent(match[1]);
@@ -1318,11 +1358,7 @@ class Daemon {
1318
1358
  return;
1319
1359
  case "approval": {
1320
1360
  if (typeof message.id !== "string") throw new Error("missing approval id");
1321
- const answer = {};
1322
- if (typeof message.value === "string") answer.value = message.value;
1323
- if (typeof message.confirmed === "boolean") answer.confirmed = message.confirmed;
1324
- if (message.cancelled === true) answer.cancelled = true;
1325
- entry.rpc.respondExtension(message.id, answer);
1361
+ entry.rpc.respondExtension(message.id, answerOf(message));
1326
1362
  ws.send(JSON.stringify({ type: "accepted", command: "approval" }));
1327
1363
  return;
1328
1364
  }
@@ -136,7 +136,7 @@ export class TranscriptStore {
136
136
  base: Number.isSafeInteger(base) && base > 0 ? base : 0,
137
137
  messages,
138
138
  state: state !== null && typeof state === "object" ? { ...state, isStreaming: false } : state,
139
- session: { ...session, status: "idle", waiting: 0 },
139
+ session: { ...session, status: "idle", waiting: 0, request: undefined },
140
140
  };
141
141
  try {
142
142
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });