mouaif 0.3.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/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/push.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// mouaif push notification subsystem
|
|
4
|
+
//
|
|
5
|
+
// Three layers:
|
|
6
|
+
// 1. VAPID key management (auto-generated, stored in SQLite)
|
|
7
|
+
// 2. Push subscription CRUD (per-session)
|
|
8
|
+
// 3. Push sending utility (tagged per-chat for updatable notifications)
|
|
9
|
+
//
|
|
10
|
+
// The ASCII status bar that rides every chat notification lives in
|
|
11
|
+
// src/statusBar.js: it decides the bar's cell count from the receiving
|
|
12
|
+
// device's screen size, notification style, and OS version. This module only
|
|
13
|
+
// stores the device's self-report and resolves it per target at send time.
|
|
14
|
+
|
|
15
|
+
const webpush = require('web-push');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const settings = require('./settings.js');
|
|
18
|
+
const statusBar = require('./statusBar.js');
|
|
19
|
+
|
|
20
|
+
const SUB_TABLE = 'push_subscriptions';
|
|
21
|
+
const VAPID_TABLE = 'push_vapid';
|
|
22
|
+
const VAPID_SUBJECT = 'mailto:push@mouaif.local';
|
|
23
|
+
|
|
24
|
+
// Fields a device may report about itself for status-bar sizing. Anything
|
|
25
|
+
// else in the subscribe body is ignored, so a malformed client cannot grow an
|
|
26
|
+
// unbounded blob in the subscription row.
|
|
27
|
+
const PROFILE_MAX_CHARS = 512;
|
|
28
|
+
|
|
29
|
+
// normalizeStatusBarProfile(value) -> JSON string | null
|
|
30
|
+
//
|
|
31
|
+
// Keep only the known sizing fields and only when they carry a usable value,
|
|
32
|
+
// so the stored profile is small and statusBarJs.statusBarPlan() can trust
|
|
33
|
+
// its types. Returns null when nothing usable was reported, so a rebind can
|
|
34
|
+
// preserve the previously stored profile.
|
|
35
|
+
function normalizeStatusBarProfile(value) {
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
37
|
+
const out = {};
|
|
38
|
+
const chars = Number(value.chars);
|
|
39
|
+
if (Number.isFinite(chars) && chars > 0) out.chars = Math.round(chars);
|
|
40
|
+
const viewportWidth = Number(value.viewportWidth);
|
|
41
|
+
if (Number.isFinite(viewportWidth) && viewportWidth > 0) out.viewportWidth = Math.round(viewportWidth);
|
|
42
|
+
const os = statusBar.normalizeOs(value.os);
|
|
43
|
+
if (os) out.os = os;
|
|
44
|
+
const osVersion = Number(value.osVersion);
|
|
45
|
+
if (Number.isFinite(osVersion) && osVersion > 0) out.osVersion = Math.round(osVersion);
|
|
46
|
+
const style = String(value.style || '').trim().toLowerCase();
|
|
47
|
+
if (style === 'collapsed' || style === 'expanded') out.style = style;
|
|
48
|
+
if (!Object.keys(out).length) return null;
|
|
49
|
+
const json = JSON.stringify(out);
|
|
50
|
+
return json.length <= PROFILE_MAX_CHARS ? json : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// statusBarFromLegacyChars(value) -> integer | null
|
|
54
|
+
//
|
|
55
|
+
// The original subscribe shape sent a bare character count. Keep honoring it
|
|
56
|
+
// so a client that predates the richer profile still sizes its bar.
|
|
57
|
+
function statusBarFromLegacyChars(value) {
|
|
58
|
+
const n = Number(value);
|
|
59
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- VAPID keys ---------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function vapidSubjectForOrigin(origin) {
|
|
65
|
+
if (!origin) return VAPID_SUBJECT;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = new URL(origin);
|
|
68
|
+
// web-push accepts mailto: or HTTPS VAPID subjects. Public iOS Web Push
|
|
69
|
+
// origins are HTTPS, so using the served origin gives Apple a stable,
|
|
70
|
+
// deployment-specific contact without requiring APNs credentials.
|
|
71
|
+
if (parsed.protocol === 'https:') return parsed.origin;
|
|
72
|
+
} catch { /* use the local fallback */ }
|
|
73
|
+
return VAPID_SUBJECT;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readVapidKeys() {
|
|
77
|
+
const db = settings.getDb();
|
|
78
|
+
const publicKey = db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'publicKey'`).pluck().get() || null;
|
|
79
|
+
const privateKey = db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'privateKey'`).pluck().get() || null;
|
|
80
|
+
return { publicKey, privateKey };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function ensureVapidKeys(origin) {
|
|
84
|
+
const db = settings.getDb();
|
|
85
|
+
const existing = readVapidKeys();
|
|
86
|
+
const subject = vapidSubjectForOrigin(origin);
|
|
87
|
+
if (existing.publicKey && existing.privateKey) {
|
|
88
|
+
webpush.setVapidDetails(subject, existing.publicKey, existing.privateKey);
|
|
89
|
+
return existing.publicKey;
|
|
90
|
+
}
|
|
91
|
+
const keys = webpush.generateVAPIDKeys();
|
|
92
|
+
const now = new Date().toISOString();
|
|
93
|
+
const save = db.prepare(`INSERT INTO ${VAPID_TABLE} (key, value, created_at) VALUES (?, ?, ?)
|
|
94
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, created_at = excluded.created_at`);
|
|
95
|
+
const saveKeys = db.transaction(() => {
|
|
96
|
+
save.run('publicKey', keys.publicKey, now);
|
|
97
|
+
save.run('privateKey', keys.privateKey, now);
|
|
98
|
+
});
|
|
99
|
+
saveKeys();
|
|
100
|
+
webpush.setVapidDetails(subject, keys.publicKey, keys.privateKey);
|
|
101
|
+
return keys.publicKey;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getVapidPublicKey() {
|
|
105
|
+
const db = settings.getDb();
|
|
106
|
+
return db.prepare(`SELECT value FROM ${VAPID_TABLE} WHERE key = 'publicKey'`).pluck().get() || null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function getPushConfig(origin) {
|
|
110
|
+
const publicKey = ensureVapidKeys(origin);
|
|
111
|
+
const keys = readVapidKeys();
|
|
112
|
+
return {
|
|
113
|
+
origin: origin || null,
|
|
114
|
+
subject: vapidSubjectForOrigin(origin),
|
|
115
|
+
publicKey,
|
|
116
|
+
privateKeyConfigured: !!keys.privateKey
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- Subscription CRUD --------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function ensureTable() {
|
|
123
|
+
const db = settings.getDb();
|
|
124
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${SUB_TABLE} (
|
|
125
|
+
id TEXT PRIMARY KEY,
|
|
126
|
+
session_id TEXT NOT NULL,
|
|
127
|
+
endpoint TEXT NOT NULL UNIQUE,
|
|
128
|
+
p256dh TEXT NOT NULL,
|
|
129
|
+
auth TEXT NOT NULL,
|
|
130
|
+
origin TEXT,
|
|
131
|
+
status_bar INTEGER,
|
|
132
|
+
status_bar_profile TEXT,
|
|
133
|
+
created_at TEXT NOT NULL,
|
|
134
|
+
updated_at TEXT NOT NULL
|
|
135
|
+
)`);
|
|
136
|
+
db.exec(`CREATE TABLE IF NOT EXISTS ${VAPID_TABLE} (
|
|
137
|
+
key TEXT PRIMARY KEY,
|
|
138
|
+
value TEXT NOT NULL,
|
|
139
|
+
created_at TEXT NOT NULL
|
|
140
|
+
)`);
|
|
141
|
+
// Devices subscribed before the status bar became device-sized have no
|
|
142
|
+
// stored report; they keep the conservative phone plan until the page
|
|
143
|
+
// rebinds its endpoint (every startup sync re-registers, so this heals).
|
|
144
|
+
// `status_bar` is the original bare character count; `status_bar_profile`
|
|
145
|
+
// is the current shape (measured chars, viewport, OS, OS version, style).
|
|
146
|
+
const cols = db.prepare(`PRAGMA table_info('${SUB_TABLE}')`).all();
|
|
147
|
+
const names = new Set(cols.map((c) => c.name));
|
|
148
|
+
if (!names.has('status_bar')) {
|
|
149
|
+
db.exec(`ALTER TABLE ${SUB_TABLE} ADD COLUMN status_bar INTEGER`);
|
|
150
|
+
}
|
|
151
|
+
if (!names.has('status_bar_profile')) {
|
|
152
|
+
db.exec(`ALTER TABLE ${SUB_TABLE} ADD COLUMN status_bar_profile TEXT`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function listSubscriptions(sessionId) {
|
|
157
|
+
if (!sessionId) return [];
|
|
158
|
+
const db = settings.getDb();
|
|
159
|
+
return db.prepare(`SELECT id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at FROM ${SUB_TABLE} WHERE session_id = ? ORDER BY created_at ASC`).all(sessionId);
|
|
160
|
+
}
|
|
161
|
+
// Every subscription, across sessions — used by the sign-in alert, which
|
|
162
|
+
// must reach the user's other already-signed-in devices. A login mints a
|
|
163
|
+
// brand-new session whose own subscription list is empty until the page
|
|
164
|
+
// rebinds its endpoint, so a per-session send would reach nothing.
|
|
165
|
+
function listAllSubscriptions() {
|
|
166
|
+
const db = settings.getDb();
|
|
167
|
+
return db.prepare(`SELECT id, session_id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at FROM ${SUB_TABLE} ORDER BY created_at ASC`).all();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
function addSubscription({ sessionId, endpoint, p256dh, auth, origin, statusBarMaxChars, statusBarProfile }) {
|
|
172
|
+
const db = settings.getDb();
|
|
173
|
+
const now = new Date().toISOString();
|
|
174
|
+
const existing = db.prepare(`SELECT id, created_at, status_bar, status_bar_profile FROM ${SUB_TABLE} WHERE endpoint = ?`).get(endpoint);
|
|
175
|
+
const id = existing ? existing.id : crypto.randomUUID();
|
|
176
|
+
// The device reports how it presents notifications (measured body line,
|
|
177
|
+
// viewport, OS, OS version, style). An absent report (an older page, a curl
|
|
178
|
+
// client) keeps whatever this device last stored — a stale client must not
|
|
179
|
+
// reset a good profile, and a rebind is the normal path that refines it.
|
|
180
|
+
const profileJson = normalizeStatusBarProfile(statusBarProfile)
|
|
181
|
+
|| (existing && existing.status_bar_profile)
|
|
182
|
+
|| null;
|
|
183
|
+
const legacyChars = statusBarFromLegacyChars(statusBarMaxChars);
|
|
184
|
+
const statusBarValue = legacyChars != null
|
|
185
|
+
? legacyChars
|
|
186
|
+
: (existing && existing.status_bar) || null;
|
|
187
|
+
db.prepare(`INSERT INTO ${SUB_TABLE} (id, session_id, endpoint, p256dh, auth, origin, status_bar, status_bar_profile, created_at, updated_at)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
189
|
+
ON CONFLICT(endpoint) DO UPDATE SET
|
|
190
|
+
session_id = excluded.session_id,
|
|
191
|
+
p256dh = excluded.p256dh,
|
|
192
|
+
auth = excluded.auth,
|
|
193
|
+
origin = excluded.origin,
|
|
194
|
+
status_bar = excluded.status_bar,
|
|
195
|
+
status_bar_profile = excluded.status_bar_profile,
|
|
196
|
+
updated_at = excluded.updated_at`)
|
|
197
|
+
.run(id, sessionId, endpoint, p256dh, auth, origin || null, statusBarValue, profileJson, existing ? existing.created_at : now, now);
|
|
198
|
+
return { id, endpoint, origin };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function removeSubscription(endpoint, sessionId) {
|
|
202
|
+
const db = settings.getDb();
|
|
203
|
+
const info = sessionId
|
|
204
|
+
? db.prepare(`DELETE FROM ${SUB_TABLE} WHERE endpoint = ? AND session_id = ?`).run(endpoint, sessionId)
|
|
205
|
+
: db.prepare(`DELETE FROM ${SUB_TABLE} WHERE endpoint = ?`).run(endpoint);
|
|
206
|
+
return info.changes > 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeAllSubscriptions(sessionId) {
|
|
210
|
+
const db = settings.getDb();
|
|
211
|
+
db.prepare(`DELETE FROM ${SUB_TABLE} WHERE session_id = ?`).run(sessionId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- Push sending -------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
// sendPush({ ... , body, bodyFor }) — deliver one notification to every
|
|
217
|
+
// target subscription.
|
|
218
|
+
//
|
|
219
|
+
// `body` is the device-independent text; `bodyFor(sub)` is the status-bar
|
|
220
|
+
// escape hatch: a status push passes a function that builds its body from the
|
|
221
|
+
// *subscription's* own device plan, so each device gets a bar sized to its
|
|
222
|
+
// screen, notification style, and OS version (see src/statusBar.js). A push
|
|
223
|
+
// without `bodyFor` sends the same `body` everywhere, which is what
|
|
224
|
+
// authorization and sign-in alerts do.
|
|
225
|
+
function sendPush({ sessionId, subs, title, body, bodyFor, tag, data, chatId, projectDir, actions, requireInteraction }) {
|
|
226
|
+
const targets = Array.isArray(subs) ? subs : (sessionId ? listSubscriptions(sessionId) : []);
|
|
227
|
+
if (!targets.length) return;
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
const keys = readVapidKeys();
|
|
231
|
+
|
|
232
|
+
for (const sub of targets) {
|
|
233
|
+
let targetBody = body;
|
|
234
|
+
if (typeof bodyFor === 'function') {
|
|
235
|
+
try {
|
|
236
|
+
const resolved = bodyFor(sub);
|
|
237
|
+
if (typeof resolved === 'string') targetBody = resolved;
|
|
238
|
+
} catch { /* fall back to the shared body */ }
|
|
239
|
+
}
|
|
240
|
+
const payload = JSON.stringify({
|
|
241
|
+
title: title || 'mouaif',
|
|
242
|
+
body: targetBody || '',
|
|
243
|
+
tag: tag || (chatId ? `chat-${chatId}` : undefined),
|
|
244
|
+
renotify: true,
|
|
245
|
+
icon: '/icons/icon-192.png',
|
|
246
|
+
badge: '/icons/favicon-32.png',
|
|
247
|
+
data: data || { chatId, projectDir, url: chatId && projectDir ? `/#/chat/${chatId}?projectDir=${encodeURIComponent(projectDir)}` : '/' },
|
|
248
|
+
actions: Array.isArray(actions) && actions.length
|
|
249
|
+
? actions
|
|
250
|
+
: [{ action: 'open', title: 'Open chat' }],
|
|
251
|
+
requireInteraction: requireInteraction === true
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const subscription = {
|
|
255
|
+
endpoint: sub.endpoint,
|
|
256
|
+
keys: { p256dh: sub.p256dh, auth: sub.auth }
|
|
257
|
+
};
|
|
258
|
+
const options = keys.publicKey && keys.privateKey ? {
|
|
259
|
+
vapidDetails: {
|
|
260
|
+
subject: vapidSubjectForOrigin(sub.origin),
|
|
261
|
+
publicKey: keys.publicKey,
|
|
262
|
+
privateKey: keys.privateKey
|
|
263
|
+
}
|
|
264
|
+
} : undefined;
|
|
265
|
+
webpush.sendNotification(subscription, payload, options).catch((err) => {
|
|
266
|
+
// 410 Gone / 404 Not Found means the subscription is dead
|
|
267
|
+
if (err && (err.statusCode === 410 || err.statusCode === 404)) {
|
|
268
|
+
removeSubscription(sub.endpoint);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// sendPushToSession — send a push notification to a specific session.
|
|
275
|
+
// Called from handleChatStream for attention, completion, and error events.
|
|
276
|
+
function sendPushToSession(sessionId, { title, body, bodyFor, chatId, projectDir, tag, data, actions, requireInteraction }) {
|
|
277
|
+
sendPush({ sessionId, title, body, bodyFor, tag, chatId, projectDir, data, actions, requireInteraction });
|
|
278
|
+
}
|
|
279
|
+
// sendPushToAll — send to every subscribed endpoint regardless of session.
|
|
280
|
+
// Used by the sign-in alert, which must reach the user's other devices even
|
|
281
|
+
// though the login created a brand-new session.
|
|
282
|
+
function sendPushToAll({ title, body, bodyFor, chatId, projectDir, tag, data, actions, requireInteraction }) {
|
|
283
|
+
sendPush({ subs: listAllSubscriptions(), title, body, bodyFor, tag, chatId, projectDir, data, actions, requireInteraction });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
// ---- Session ID helpers -------------------------------------------------
|
|
288
|
+
|
|
289
|
+
// Derive a stable session id from the session token. One-way hash so the
|
|
290
|
+
// token itself is never stored alongside subscriptions.
|
|
291
|
+
function sessionIdFromToken(token) {
|
|
292
|
+
if (!token) return null;
|
|
293
|
+
return crypto.createHash('sha256').update('push:' + token).digest('hex').slice(0, 16);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
ensureTable,
|
|
298
|
+
ensureVapidKeys,
|
|
299
|
+
getVapidPublicKey,
|
|
300
|
+
getPushConfig,
|
|
301
|
+
vapidSubjectForOrigin,
|
|
302
|
+
normalizeStatusBarProfile,
|
|
303
|
+
// Status-bar surface re-exported so senders have one import for "build a
|
|
304
|
+
// status body": `push.statusBar` is src/statusBar.js.
|
|
305
|
+
statusBar,
|
|
306
|
+
listSubscriptions,
|
|
307
|
+
listAllSubscriptions,
|
|
308
|
+
addSubscription,
|
|
309
|
+
removeSubscription,
|
|
310
|
+
removeAllSubscriptions,
|
|
311
|
+
sendPush,
|
|
312
|
+
sendPushToSession,
|
|
313
|
+
sendPushToAll,
|
|
314
|
+
sessionIdFromToken
|
|
315
|
+
};
|
package/src/qr.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Small dependency-free QR encoder for setup URLs. Byte mode, EC level L,
|
|
4
|
+
// versions 1–10 (enough for normal mouaif origins and setup codes).
|
|
5
|
+
|
|
6
|
+
const BLOCKS_L = [
|
|
7
|
+
null,
|
|
8
|
+
[[1, 26, 19]], [[1, 44, 34]], [[1, 70, 55]], [[1, 100, 80]],
|
|
9
|
+
[[1, 134, 108]], [[2, 86, 68]], [[2, 98, 78]], [[2, 121, 97]],
|
|
10
|
+
[[2, 146, 116]], [[2, 86, 68], [2, 87, 69]]
|
|
11
|
+
];
|
|
12
|
+
const ALIGN = [null, [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
|
|
13
|
+
const EXP = new Array(512);
|
|
14
|
+
const LOG = new Array(256);
|
|
15
|
+
let value = 1;
|
|
16
|
+
for (let i = 0; i < 255; i++) { EXP[i] = value; LOG[value] = i; value <<= 1; if (value & 0x100) value ^= 0x11d; }
|
|
17
|
+
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
|
|
18
|
+
|
|
19
|
+
function append(bits, value, length) {
|
|
20
|
+
for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function multiplyPoly(a, b) {
|
|
24
|
+
const out = new Array(a.length + b.length - 1).fill(0);
|
|
25
|
+
for (let i = 0; i < a.length; i++) for (let j = 0; j < b.length; j++) {
|
|
26
|
+
if (a[i] && b[j]) out[i + j] ^= EXP[LOG[a[i]] + LOG[b[j]]];
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function errorCodewords(data, count) {
|
|
32
|
+
let generator = [1];
|
|
33
|
+
for (let i = 0; i < count; i++) generator = multiplyPoly(generator, [1, EXP[i]]);
|
|
34
|
+
const work = data.concat(new Array(count).fill(0));
|
|
35
|
+
for (let i = 0; i < data.length; i++) {
|
|
36
|
+
const factor = work[i];
|
|
37
|
+
if (!factor) continue;
|
|
38
|
+
const log = LOG[factor];
|
|
39
|
+
for (let j = 0; j < generator.length; j++) if (generator[j]) work[i + j] ^= EXP[LOG[generator[j]] + log];
|
|
40
|
+
}
|
|
41
|
+
return work.slice(data.length);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function blocksFor(version) {
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [count, total, data] of BLOCKS_L[version]) for (let i = 0; i < count; i++) out.push({ total, data });
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function codewords(text, version) {
|
|
51
|
+
const bytes = Buffer.from(String(text), 'utf8');
|
|
52
|
+
const blocks = blocksFor(version);
|
|
53
|
+
const dataCount = blocks.reduce((sum, block) => sum + block.data, 0);
|
|
54
|
+
const bits = [];
|
|
55
|
+
append(bits, 4, 4); // byte mode
|
|
56
|
+
append(bits, bytes.length, version < 10 ? 8 : 16);
|
|
57
|
+
for (const byte of bytes) append(bits, byte, 8);
|
|
58
|
+
if (bits.length + 4 <= dataCount * 8) append(bits, 0, 4);
|
|
59
|
+
while (bits.length % 8) bits.push(0);
|
|
60
|
+
const data = [];
|
|
61
|
+
for (let i = 0; i < bits.length; i += 8) {
|
|
62
|
+
let byte = 0;
|
|
63
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
|
|
64
|
+
data.push(byte);
|
|
65
|
+
}
|
|
66
|
+
for (let pad = 0; data.length < dataCount; pad++) data.push(pad % 2 ? 0x11 : 0xec);
|
|
67
|
+
const chunks = [];
|
|
68
|
+
const errors = [];
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const block of blocks) {
|
|
71
|
+
const chunk = data.slice(offset, offset + block.data);
|
|
72
|
+
offset += block.data;
|
|
73
|
+
chunks.push(chunk);
|
|
74
|
+
errors.push(errorCodewords(chunk, block.total - block.data));
|
|
75
|
+
}
|
|
76
|
+
const out = [];
|
|
77
|
+
const maxData = Math.max(...chunks.map((chunk) => chunk.length));
|
|
78
|
+
const maxError = Math.max(...errors.map((chunk) => chunk.length));
|
|
79
|
+
for (let i = 0; i < maxData; i++) for (const chunk of chunks) if (i < chunk.length) out.push(chunk[i]);
|
|
80
|
+
for (let i = 0; i < maxError; i++) for (const chunk of errors) if (i < chunk.length) out.push(chunk[i]);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function bch(value, polynomial) {
|
|
85
|
+
let shifted = value;
|
|
86
|
+
const degree = (number) => 31 - Math.clz32(number);
|
|
87
|
+
while (degree(shifted) >= degree(polynomial)) shifted ^= polynomial << (degree(shifted) - degree(polynomial));
|
|
88
|
+
return shifted;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function finder(matrix, row, col) {
|
|
92
|
+
const size = matrix.length;
|
|
93
|
+
for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
|
|
94
|
+
if (row + r < 0 || row + r >= size || col + c < 0 || col + c >= size) continue;
|
|
95
|
+
matrix[row + r][col + c] = r >= 0 && r <= 6 && c >= 0 && c <= 6 && (r === 0 || r === 6 || c === 0 || c === 6 || (r >= 2 && r <= 4 && c >= 2 && c <= 4));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function makeMatrix(text) {
|
|
100
|
+
const bytes = Buffer.from(String(text), 'utf8');
|
|
101
|
+
let version = 1;
|
|
102
|
+
while (version <= 10) {
|
|
103
|
+
const dataCount = blocksFor(version).reduce((sum, block) => sum + block.data, 0);
|
|
104
|
+
const lengthBits = version < 10 ? 8 : 16;
|
|
105
|
+
if (4 + lengthBits + bytes.length * 8 <= dataCount * 8) break;
|
|
106
|
+
version++;
|
|
107
|
+
}
|
|
108
|
+
if (version > 10) throw new Error('QR content is too long');
|
|
109
|
+
const size = version * 4 + 17;
|
|
110
|
+
const matrix = Array.from({ length: size }, () => Array(size).fill(null));
|
|
111
|
+
finder(matrix, 0, 0); finder(matrix, size - 7, 0); finder(matrix, 0, size - 7);
|
|
112
|
+
for (let i = 8; i < size - 8; i++) {
|
|
113
|
+
if (matrix[i][6] === null) matrix[i][6] = i % 2 === 0;
|
|
114
|
+
if (matrix[6][i] === null) matrix[6][i] = i % 2 === 0;
|
|
115
|
+
}
|
|
116
|
+
for (const row of ALIGN[version]) for (const col of ALIGN[version]) {
|
|
117
|
+
if (matrix[row][col] !== null) continue;
|
|
118
|
+
for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++) matrix[row + r][col + c] = Math.max(Math.abs(r), Math.abs(c)) !== 1;
|
|
119
|
+
}
|
|
120
|
+
if (version >= 7) {
|
|
121
|
+
const bits = (version << 12) | bch(version << 12, 0x1f25);
|
|
122
|
+
for (let i = 0; i < 18; i++) {
|
|
123
|
+
const bit = ((bits >> i) & 1) === 1;
|
|
124
|
+
matrix[Math.floor(i / 3)][i % 3 + size - 11] = bit;
|
|
125
|
+
matrix[i % 3 + size - 11][Math.floor(i / 3)] = bit;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// Error correction L has format bits 01. Use mask pattern 0.
|
|
129
|
+
const format = ((1 << 3) | 0) << 10;
|
|
130
|
+
const formatBits = (format | bch(format, 0x537)) ^ 0x5412;
|
|
131
|
+
for (let i = 0; i < 15; i++) {
|
|
132
|
+
const bit = ((formatBits >> i) & 1) === 1;
|
|
133
|
+
if (i < 6) matrix[i][8] = bit;
|
|
134
|
+
else if (i < 8) matrix[i + 1][8] = bit;
|
|
135
|
+
else matrix[size - 15 + i][8] = bit;
|
|
136
|
+
if (i < 8) matrix[8][size - i - 1] = bit;
|
|
137
|
+
else if (i === 8) matrix[8][7] = bit;
|
|
138
|
+
else matrix[8][15 - i - 1] = bit;
|
|
139
|
+
}
|
|
140
|
+
matrix[size - 8][8] = true;
|
|
141
|
+
const data = codewords(text, version);
|
|
142
|
+
let bitIndex = 0;
|
|
143
|
+
let upward = true;
|
|
144
|
+
for (let right = size - 1; right > 0; right -= 2) {
|
|
145
|
+
if (right === 6) right--;
|
|
146
|
+
for (let step = 0; step < size; step++) {
|
|
147
|
+
const row = upward ? size - 1 - step : step;
|
|
148
|
+
for (let side = 0; side < 2; side++) {
|
|
149
|
+
const col = right - side;
|
|
150
|
+
if (matrix[row][col] !== null) continue;
|
|
151
|
+
const byte = data[Math.floor(bitIndex / 8)] || 0;
|
|
152
|
+
let bit = ((byte >>> (7 - (bitIndex % 8))) & 1) === 1;
|
|
153
|
+
if ((row + col) % 2 === 0) bit = !bit; // mask 0
|
|
154
|
+
matrix[row][col] = bit;
|
|
155
|
+
bitIndex++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
upward = !upward;
|
|
159
|
+
}
|
|
160
|
+
return matrix;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function svg(text, options = {}) {
|
|
164
|
+
const matrix = makeMatrix(text);
|
|
165
|
+
const quiet = 4;
|
|
166
|
+
const size = matrix.length + quiet * 2;
|
|
167
|
+
let path = '';
|
|
168
|
+
for (let y = 0; y < matrix.length; y++) for (let x = 0; x < matrix.length; x++) if (matrix[y][x]) path += `M${x + quiet} ${y + quiet}h1v1h-1z`;
|
|
169
|
+
const px = Number(options.size) || 256;
|
|
170
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${px}" height="${px}" role="img" aria-label="Setup QR code"><rect width="100%" height="100%" fill="#fff"/><path d="${path}" fill="#000" shape-rendering="crispEdges"/></svg>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function terminal(text) {
|
|
174
|
+
const matrix = makeMatrix(text);
|
|
175
|
+
const quiet = 2;
|
|
176
|
+
const rows = [];
|
|
177
|
+
const width = matrix.length + quiet * 2;
|
|
178
|
+
const white = new Array(width).fill(false);
|
|
179
|
+
const padded = [white, white, ...matrix.map((row) => [...new Array(quiet).fill(false), ...row, ...new Array(quiet).fill(false)]), white, white];
|
|
180
|
+
// Force a white cell background and black modules rather than relying on
|
|
181
|
+
// the terminal theme (a light foreground on a dark terminal inverts QR).
|
|
182
|
+
for (let y = 0; y < padded.length; y += 2) {
|
|
183
|
+
let line = '\x1b[30;47m';
|
|
184
|
+
const top = padded[y];
|
|
185
|
+
const bottom = padded[y + 1] || white;
|
|
186
|
+
for (let x = 0; x < width; x++) line += top[x] ? (bottom[x] ? '\x1b[40m \x1b[47m' : '▀') : (bottom[x] ? '▄' : ' ');
|
|
187
|
+
rows.push(line + '\x1b[0m');
|
|
188
|
+
}
|
|
189
|
+
return rows.join('\n');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { makeMatrix, svg, terminal };
|
package/src/restart.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Shared graceful-restart scheduler used by the HTTP API and the native
|
|
3
|
+
// restart_app chat tool. The caller sends its response/tool result first;
|
|
4
|
+
// the delayed callback then stops MCP children and hands control back to
|
|
5
|
+
// the serve supervisor through lifecycle.restart().
|
|
6
|
+
const mcp = require('./mcp.js');
|
|
7
|
+
|
|
8
|
+
function clampDelay(value, fallback = 150) {
|
|
9
|
+
return Number.isFinite(value) ? Math.max(0, Math.min(Math.round(value), 5000)) : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function requestRestart(options = {}) {
|
|
13
|
+
const lifecycle = options.lifecycle || {};
|
|
14
|
+
const reason = typeof options.reason === 'string' && options.reason.trim()
|
|
15
|
+
? options.reason.trim().slice(0, 500)
|
|
16
|
+
: 'user-requested';
|
|
17
|
+
const delayMs = clampDelay(options.delayMs, Number.isFinite(options.defaultDelayMs) ? options.defaultDelayMs : 150);
|
|
18
|
+
if (lifecycle.restarting) {
|
|
19
|
+
return { ok: false, restarting: true, error: 'Restart already in progress' };
|
|
20
|
+
}
|
|
21
|
+
lifecycle.restarting = true;
|
|
22
|
+
const result = {
|
|
23
|
+
ok: true,
|
|
24
|
+
restarting: true,
|
|
25
|
+
reason,
|
|
26
|
+
delayMs,
|
|
27
|
+
mode: typeof lifecycle.restart === 'function' ? 'relaunch' : 'exit'
|
|
28
|
+
};
|
|
29
|
+
setTimeout(async () => {
|
|
30
|
+
try { process.stdout.write('[mouaif] restart requested: ' + reason + '\n'); } catch (_) {}
|
|
31
|
+
try { await mcp.stopAll(); } catch (_) { /* best-effort */ }
|
|
32
|
+
if (typeof lifecycle.restart === 'function') {
|
|
33
|
+
try {
|
|
34
|
+
await lifecycle.restart({ reason });
|
|
35
|
+
lifecycle.restarting = false;
|
|
36
|
+
return;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
try { process.stderr.write('[mouaif] restart failed: ' + (error && error.message || error) + '\n'); } catch (_) {}
|
|
39
|
+
lifecycle.restarting = false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}, delayMs).unref();
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { requestRestart, clampDelay };
|