@oar-sh/oar 0.9.8 → 0.9.9

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 (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.8",
3
+ "version": "0.9.9",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -50,9 +50,9 @@
50
50
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "^0.3.283",
54
- "@cursor/sdk": "^1.0.32",
55
- "@github/copilot-sdk": "^1.0.14",
53
+ "@anthropic-ai/claude-agent-sdk": "^0.3.292",
54
+ "@cursor/sdk": "^1.0.36",
55
+ "@github/copilot-sdk": "^1.0.17",
56
56
  "better-sqlite3": "^12.10.0",
57
57
  "express": "^4.18.2",
58
58
  "qrcode-terminal": "^0.12.0",
@@ -63,7 +63,6 @@
63
63
  "zod": "^4.4.3"
64
64
  },
65
65
  "optionalDependencies": {
66
- "cloudflared": "^0.7.1",
67
66
  "tty-console": "^1.0.4"
68
67
  },
69
68
  "devDependencies": {
package/server/README.md CHANGED
@@ -658,6 +658,24 @@ images, calling the OpenAI Images API directly rather than going through a CLI w
658
658
  is never silently lost.
659
659
  - `POST /api/shared/:token/presence` tracks shared-viewer presence, surfaced as a watcher count.
660
660
 
661
+ ## Pinned messages
662
+
663
+ - `PATCH /api/conversation/:id/message/:messageId/pin` with `{ pinned: boolean }` sets or clears
664
+ `messages.pinned_at`. A conversation holds at most 100 pins; the next one is refused with `409`
665
+ and `code: "pin-limit"`. The answer is
666
+ `{ ok, conversationId, messageId, pinned, pins, pinsRevision }`.
667
+ - `GET /api/conversation/:id` carries `pins`: every pinned message of the conversation in
668
+ conversation order, also those outside the requested history page, each as
669
+ `{ messageId, role, preview, timestamp, pinnedAt, attachmentCount, hiddenFromShares }`.
670
+ `preview` is plain text of at most 160 characters. Each message also carries `pinned`.
671
+ - Every change is broadcast as the socket event `conversation_pins_updated` with
672
+ `{ conversationId, pins, revision }`. `revision` (`pinsRevision` in the HTTP answers) grows with
673
+ every change. A conversation load that left the relay before a change can reach the browser
674
+ after the change's own event; the browser keeps the list with the higher revision.
675
+ - Shared views carry neither `pins` nor `pinned`: pins are the owner's bookmarks.
676
+ - Pins belong to message rows. `/compact` starts a new conversation without them (they stay with
677
+ the archived one); re-importing an imported session keeps them, matched like hidden marks.
678
+
661
679
  ## Relay Mode Selection
662
680
 
663
681
  The composer also includes a per-message mode picker:
@@ -973,6 +991,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
973
991
  | POST | `/api/subagent-run` | (Worker) Register/update a subagent run for the active turn |
974
992
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
975
993
  | PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
994
+ | PATCH | `/api/conversation/:id/message/:messageId/pin` | `{ pinned }` → pin or unpin a single message; answers with the conversation's pin list and broadcasts `conversation_pins_updated` |
976
995
  | POST | `/api/conversation/:id/share` | Create or update the conversation's read-only share token |
977
996
  | GET | `/api/shared/:token` | Read-only shared conversation view (hidden messages filtered out) |
978
997
  | POST | `/api/shared/:token/presence` | Report shared-viewer presence for the watcher count |
@@ -1381,7 +1400,7 @@ service).
1381
1400
  | `cloudflaredTunnel.enabled` | `false` | Legacy alias for mode (`true` => `managed`) |
1382
1401
  | `cloudflaredTunnel.required` | `false` | Pause dequeue when the Cloudflare tunnel is disconnected in managed mode |
1383
1402
  | `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
1384
- | `cloudflaredTunnel.binary` | *(auto)* | `cloudflared` path; falls back to the optional npm package, then `PATH` |
1403
+ | `cloudflaredTunnel.binary` | `cloudflared` | Path or command name of the `cloudflared` you installed (OAR ships none); defaults to `cloudflared` on `PATH`. Without one the tunnel stays off and the status says how to install it |
1385
1404
  | `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
1386
1405
  | `tunnelMarkerHeaders` | `[]` | Extra edge-injected header names that mark tunnel traffic for the session-worker path guard |
1387
1406
 
@@ -13,6 +13,7 @@ import { migrateSteerSettleMarkers } from './migrations/0004-steer-settle-marker
13
13
  import { migrateRemoteRelays } from './migrations/0005-remote-relays.mjs';
14
14
  import { migrateWorkspaceRootSettings } from './migrations/0006-workspace-root-settings.mjs';
15
15
  import { migrateClaudeCloud } from './migrations/0007-claude-cloud.mjs';
16
+ import { migrateMessagePins } from './migrations/0008-message-pins.mjs';
16
17
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
17
18
 
18
19
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -734,6 +735,13 @@ try {
734
735
  } catch (error) {
735
736
  console.warn(`[claude-cloud] migration 0007 failed; retrying on next boot: ${error?.message || error}`);
736
737
  }
738
+ // Pinned messages: one additive column and its partial index. Readers tolerate
739
+ // their absence (no pins), so a failure is logged and retried next boot like 0007.
740
+ try {
741
+ migrateMessagePins(db);
742
+ } catch (error) {
743
+ console.warn(`[message-pins] migration 0008 failed; retrying on next boot: ${error?.message || error}`);
744
+ }
737
745
 
738
746
  const runtimeSessionColumns = db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((c) => c.name);
739
747
  if (runtimeSessionColumns.length) {
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: pinned messages.
5
+ *
6
+ * 1. `messages.pinned_at` — when the user pinned the message (ISO time); NULL
7
+ * means not pinned. A pin is a property of the message row, like
8
+ * `hidden_from_shares`, so it goes away with the row.
9
+ * 2. `idx_messages_pinned` — a partial index over the pinned rows only: the
10
+ * pin list travels with every conversation load.
11
+ *
12
+ * Additive and idempotent. Safe to run on every boot.
13
+ *
14
+ * Usage: node server/migrations/0008-message-pins.mjs [path/to/copilot.db]
15
+ */
16
+
17
+ import { fileURLToPath } from 'url';
18
+ import path from 'path';
19
+ import process from 'process';
20
+ import Database from 'better-sqlite3';
21
+
22
+ function tableExists(db, name) {
23
+ return !!db
24
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
25
+ .get(name);
26
+ }
27
+
28
+ function indexExists(db, name) {
29
+ return !!db
30
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`)
31
+ .get(name);
32
+ }
33
+
34
+ function columnNames(db, table) {
35
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
36
+ }
37
+
38
+ export function migrateMessagePins(db) {
39
+ if (!tableExists(db, 'messages')) return { applied: false, columnsAdded: 0, indexesAdded: 0 };
40
+ let columnsAdded = 0;
41
+ let indexesAdded = 0;
42
+ if (!columnNames(db, 'messages').has('pinned_at')) {
43
+ db.exec(`ALTER TABLE messages ADD COLUMN pinned_at TEXT`);
44
+ columnsAdded += 1;
45
+ }
46
+ if (!indexExists(db, 'idx_messages_pinned')) {
47
+ db.exec(`
48
+ CREATE INDEX IF NOT EXISTS idx_messages_pinned
49
+ ON messages(conversation_id, timestamp)
50
+ WHERE pinned_at IS NOT NULL
51
+ `);
52
+ indexesAdded += 1;
53
+ }
54
+ return { applied: columnsAdded + indexesAdded > 0, columnsAdded, indexesAdded };
55
+ }
56
+
57
+ function defaultDbPath() {
58
+ const here = path.dirname(fileURLToPath(import.meta.url));
59
+ return path.resolve(here, '..', 'data', 'copilot.db');
60
+ }
61
+
62
+ export function migrate(dbPath, options = {}) {
63
+ const db = new Database(dbPath, options);
64
+ try {
65
+ return migrateMessagePins(db);
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
72
+ if (isMain) {
73
+ const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
74
+ try {
75
+ const result = migrate(dbPath);
76
+ console.log(`Migration 0008 on ${dbPath}`);
77
+ console.log(result.applied
78
+ ? ` message pins: columns added ${result.columnsAdded}, indexes added ${result.indexesAdded}`
79
+ : ' skipped: nothing to change');
80
+ process.exit(0);
81
+ } catch (err) {
82
+ console.error(`Migration 0008 failed: ${err?.message || err}`);
83
+ process.exit(1);
84
+ }
85
+ }
@@ -900,6 +900,36 @@ export async function updateMessageShareVisibility(conversationId, messageId, hi
900
900
  });
901
901
  }
902
902
 
903
+ // Pin or unpin one message. Unlike apiFetch this keeps the relay's `error`
904
+ // text on a refusal (the pin limit), so the notice can say why.
905
+ export async function updateMessagePin(conversationId, messageId, pinned) {
906
+ const convId = String(conversationId || '').trim();
907
+ const msgId = String(messageId || '').trim();
908
+ if (!convId || !msgId || !networkRequestsEnabled) return null;
909
+ const url = `/api/conversation/${encodeURIComponent(convId)}/message/${encodeURIComponent(msgId)}/pin`;
910
+ try {
911
+ const response = await fetch(`${BASE}${url}`, {
912
+ signal: requestTimeoutSignal(),
913
+ method: 'PATCH',
914
+ headers: {
915
+ 'Content-Type': 'application/json',
916
+ ...authHeaders(),
917
+ },
918
+ body: JSON.stringify({ pinned: pinned === true }),
919
+ });
920
+ const result = await response.json().catch(() => null);
921
+ if (!response.ok) {
922
+ console.error('API error', response.status, url);
923
+ return { ok: false, status: response.status, error: String(result?.error || '').trim() };
924
+ }
925
+ noteFetchSuccess();
926
+ return result;
927
+ } catch (error) {
928
+ noteFetchFailure(url, error);
929
+ return null;
930
+ }
931
+ }
932
+
903
933
  export async function updateConversationTitle(id, title) {
904
934
  const convId = String(id || '').trim();
905
935
  if (!convId) return null;