viveworker 0.1.1 → 0.1.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuta Hoshino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
+ ![viveworker social preview](./assets/app-screenshot.png)
2
+
1
3
  # viveworker
2
4
 
5
+ [![npm version](https://badge.fury.io/js/viveworker.svg)](https://badge.fury.io/js/viveworker)
6
+ [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
+
3
8
  `viveworker` brings Codex Desktop to your iPhone.
4
9
 
5
10
  When Codex needs an approval, asks whether to implement a plan, wants you to choose from options, or finishes a task while you are away from your desk, `viveworker` keeps all of that within reach on your phone. Instead of breaking your rhythm, it helps you keep vivecoding going from anywhere in your home or office.
package/package.json CHANGED
@@ -1,15 +1,25 @@
1
1
  {
2
2
  "name": "viveworker",
3
- "version": "0.1.1",
4
- "description": "A local iPhone companion for Codex Desktop approvals, plans, questions, and completions.",
5
- "author": "oeneril <hoshino.lireneo@gmail.com>",
3
+ "version": "0.1.3",
4
+ "description": "Local iPhone companion for Codex Desktop approvals, plan checks, questions, and notifications on your LAN.",
5
+ "author": "Yuta Hoshino <hoshino.lireneo@gmail.com>",
6
+ "license": "MIT",
6
7
  "keywords": [
7
- "vivecoding",
8
8
  "codex",
9
- "remote"
9
+ "codex-desktop",
10
+ "macos",
11
+ "iphone",
12
+ "ios",
13
+ "pwa",
14
+ "web-push",
15
+ "notifications",
16
+ "approvals",
17
+ "lan",
18
+ "companion-app",
19
+ "vivecoding"
10
20
  ],
11
21
  "homepage": "https://lp.hazbase.com/",
12
- "repository": "viveworker/viveworker",
22
+ "repository": "viveworker-dev/viveworker",
13
23
  "type": "module",
14
24
  "bin": {
15
25
  "viveworker": "./scripts/viveworker.mjs"
@@ -31,6 +31,9 @@ const DEFAULT_DEVICE_TRUST_TTL_MS = 30 * 24 * 60 * 60 * 1000;
31
31
  const MAX_PAIRED_DEVICES = 200;
32
32
  const PAIRING_RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
33
33
  const PAIRING_RATE_LIMIT_MAX_ATTEMPTS = 8;
34
+ const DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES = 15 * 1024 * 1024;
35
+ const DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS = 24 * 60 * 60 * 1000;
36
+ const MAX_COMPLETION_REPLY_IMAGE_COUNT = 1;
34
37
 
35
38
  const cli = parseCliArgs(process.argv.slice(2));
36
39
  const envFile = resolveEnvFile(cli.envFile);
@@ -4406,6 +4409,7 @@ function readSession(req, config, state) {
4406
4409
  pairedAtMs: Number(payload.pairedAtMs) || 0,
4407
4410
  expiresAtMs: Number(payload.expiresAtMs) || 0,
4408
4411
  deviceId,
4412
+ temporaryPairing: payload?.temporaryPairing === true,
4409
4413
  };
4410
4414
  }
4411
4415
 
@@ -4463,6 +4467,22 @@ function setSessionCookie(res, config) {
4463
4467
  }));
4464
4468
  }
4465
4469
 
4470
+ function setTemporarySessionCookie(res, config) {
4471
+ const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
4472
+ const now = Date.now();
4473
+ const token = signSessionPayload({
4474
+ sessionId: crypto.randomUUID(),
4475
+ pairedAtMs: now,
4476
+ expiresAtMs: now + config.sessionTtlMs,
4477
+ temporaryPairing: true,
4478
+ }, config.sessionSecret);
4479
+ res.setHeader("Set-Cookie", buildSetCookieHeader({
4480
+ value: token,
4481
+ maxAgeSecs: Math.max(1, Math.floor(config.sessionTtlMs / 1000)),
4482
+ secure,
4483
+ }));
4484
+ }
4485
+
4466
4486
  function clearSessionCookie(res, config) {
4467
4487
  const secure = config.nativeApprovalPublicBaseUrl.startsWith("https://");
4468
4488
  res.setHeader("Set-Cookie", buildSetCookieHeader({ value: "", maxAgeSecs: 0, secure }));
@@ -4620,15 +4640,27 @@ function pairingCredentialConsumed(config, state) {
4620
4640
  }
4621
4641
 
4622
4642
  function isPairingAvailableForState(config, state) {
4623
- return isPairingAvailable(config) && !pairingCredentialConsumed(config, state);
4643
+ return isPairingAvailable(config) && !pairingCodeConsumed(config, state);
4624
4644
  }
4625
4645
 
4626
- function markPairingConsumed(state, config, now = Date.now()) {
4627
- const current = currentPairingCredential(config);
4646
+ function pairingCodeConsumed(config, state) {
4647
+ const code = cleanText(config?.pairingCode ?? "").toUpperCase();
4648
+ if (!code) {
4649
+ return false;
4650
+ }
4651
+ const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
4652
+ const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
4653
+ return consumedAtMs > 0 && consumedCredential === `code:${code}`;
4654
+ }
4655
+
4656
+ function markPairingConsumed(state, credential, now = Date.now()) {
4657
+ const current = cleanText(credential || "");
4628
4658
  if (!current) {
4629
4659
  return false;
4630
4660
  }
4631
- if (pairingCredentialConsumed(config, state)) {
4661
+ const consumedAtMs = Number(state?.pairingConsumedAt) || 0;
4662
+ const consumedCredential = cleanText(state?.pairingConsumedCredential ?? "");
4663
+ if (consumedAtMs > 0 && consumedCredential === current) {
4632
4664
  return false;
4633
4665
  }
4634
4666
  state.pairingConsumedAt = now;
@@ -4640,7 +4672,7 @@ function validatePairingPayload(payload, config, state) {
4640
4672
  if (!config.authRequired) {
4641
4673
  return { ok: true };
4642
4674
  }
4643
- if (!isPairingAvailableForState(config, state)) {
4675
+ if (!isPairingAvailable(config)) {
4644
4676
  return { ok: false, error: "pairing-unavailable" };
4645
4677
  }
4646
4678
 
@@ -4648,10 +4680,16 @@ function validatePairingPayload(payload, config, state) {
4648
4680
  const token = cleanText(payload?.token ?? "");
4649
4681
  const matchesCode = code && cleanText(config.pairingCode).toUpperCase() === code;
4650
4682
  const matchesToken = token && cleanText(config.pairingToken) === token;
4651
- if (!matchesCode && !matchesToken) {
4652
- return { ok: false, error: "invalid-pairing-credentials" };
4683
+ if (matchesToken) {
4684
+ return { ok: true, credential: `token:${token}` };
4653
4685
  }
4654
- return { ok: true };
4686
+ if (matchesCode) {
4687
+ if (pairingCodeConsumed(config, state)) {
4688
+ return { ok: false, error: "pairing-unavailable" };
4689
+ }
4690
+ return { ok: true, credential: `code:${code}` };
4691
+ }
4692
+ return { ok: false, error: "invalid-pairing-credentials" };
4655
4693
  }
4656
4694
 
4657
4695
  function readRemoteAddress(req) {
@@ -5437,8 +5475,25 @@ async function submitGenericUserInputDecision({ config, runtime, state, userInpu
5437
5475
  }
5438
5476
  }
5439
5477
 
5440
- async function handleCompletionReply({ runtime, completionItem, text, planMode = false, force = false }) {
5478
+ function normalizeCompletionReplyLocalImagePaths(paths) {
5479
+ if (!Array.isArray(paths)) {
5480
+ return [];
5481
+ }
5482
+ return paths
5483
+ .map((value) => resolvePath(cleanText(value || "")))
5484
+ .filter(Boolean);
5485
+ }
5486
+
5487
+ async function handleCompletionReply({
5488
+ runtime,
5489
+ completionItem,
5490
+ text,
5491
+ planMode = false,
5492
+ force = false,
5493
+ localImagePaths = [],
5494
+ }) {
5441
5495
  const messageText = cleanText(text ?? "");
5496
+ const normalizedLocalImagePaths = normalizeCompletionReplyLocalImagePaths(localImagePaths);
5442
5497
  if (!messageText) {
5443
5498
  throw new Error("completion-reply-empty");
5444
5499
  }
@@ -5472,6 +5527,10 @@ async function handleCompletionReply({ runtime, completionItem, text, planMode =
5472
5527
  const turnStartParams = {
5473
5528
  input: buildTextInput(messageText),
5474
5529
  attachments: [],
5530
+ localImagePaths: normalizedLocalImagePaths,
5531
+ local_image_paths: normalizedLocalImagePaths,
5532
+ remoteImageUrls: [],
5533
+ remote_image_urls: [],
5475
5534
  cwd: null,
5476
5535
  approvalPolicy: null,
5477
5536
  sandboxPolicy: null,
@@ -5683,7 +5742,7 @@ function resolveManifestPairingToken({ config, state, requestedToken }) {
5683
5742
  if (!token) {
5684
5743
  return "";
5685
5744
  }
5686
- if (!isPairingAvailableForState(config, state)) {
5745
+ if (!isPairingAvailable(config)) {
5687
5746
  return "";
5688
5747
  }
5689
5748
  return cleanText(config.pairingToken) === token ? token : "";
@@ -5836,6 +5895,7 @@ function createNativeApprovalServer({ config, runtime, state }) {
5836
5895
  httpsEnabled: config.nativeApprovalPublicBaseUrl.startsWith("https://"),
5837
5896
  appVersion: appPackageVersion,
5838
5897
  deviceId: session.deviceId || null,
5898
+ temporaryPairing: session.temporaryPairing === true,
5839
5899
  ...buildSessionLocalePayload(config, state, session.deviceId),
5840
5900
  });
5841
5901
  }
@@ -5860,6 +5920,17 @@ function createNativeApprovalServer({ config, runtime, state }) {
5860
5920
  return writeJson(res, 400, { error: validation.error });
5861
5921
  }
5862
5922
 
5923
+ if (payload?.temporary === true && cleanText(payload?.token || "")) {
5924
+ clearPairingFailures(runtime, remoteAddress);
5925
+ setTemporarySessionCookie(res, config);
5926
+ return writeJson(res, 200, {
5927
+ ok: true,
5928
+ authenticated: true,
5929
+ pairingAvailable: isPairingAvailableForState(config, state),
5930
+ temporaryPairing: true,
5931
+ });
5932
+ }
5933
+
5863
5934
  const pairedDeviceId = readDeviceId(req, config) || crypto.randomUUID();
5864
5935
  if ("detectedLocale" in payload) {
5865
5936
  upsertDetectedDeviceLocale(state, pairedDeviceId, payload.detectedLocale);
@@ -5874,7 +5945,9 @@ function createNativeApprovalServer({ config, runtime, state }) {
5874
5945
  lastLocale: normalizeSupportedLocale(payload?.detectedLocale),
5875
5946
  }
5876
5947
  );
5877
- markPairingConsumed(state, config);
5948
+ if (String(validation.credential || "").startsWith("code:")) {
5949
+ markPairingConsumed(state, validation.credential);
5950
+ }
5878
5951
  clearPairingFailures(runtime, remoteAddress);
5879
5952
  await saveState(config.stateFile, state);
5880
5953
  setPairingCookies(res, config, pairedDeviceId);
@@ -6112,22 +6185,36 @@ function createNativeApprovalServer({ config, runtime, state }) {
6112
6185
  }
6113
6186
 
6114
6187
  try {
6115
- const payload = await parseJsonBody(req);
6188
+ const contentType = String(req.headers["content-type"] || "");
6189
+ const payload = contentType.includes("multipart/form-data")
6190
+ ? await stageCompletionReplyImages(config, req)
6191
+ : await parseJsonBody(req);
6116
6192
  await handleCompletionReply({
6117
6193
  runtime,
6118
6194
  completionItem,
6119
6195
  text: payload?.text ?? "",
6120
6196
  planMode: payload?.planMode === true,
6121
6197
  force: payload?.force === true,
6198
+ localImagePaths: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths : [],
6122
6199
  });
6123
6200
  return writeJson(res, 200, {
6124
6201
  ok: true,
6125
6202
  planMode: payload?.planMode === true,
6203
+ imageCount: Array.isArray(payload?.localImagePaths) ? payload.localImagePaths.length : 0,
6126
6204
  });
6127
6205
  } catch (error) {
6128
6206
  if (error.message === "completion-reply-empty") {
6129
6207
  return writeJson(res, 400, { error: error.message });
6130
6208
  }
6209
+ if (
6210
+ error.message === "completion-reply-image-limit" ||
6211
+ error.message === "completion-reply-image-invalid-type" ||
6212
+ error.message === "completion-reply-image-too-large" ||
6213
+ error.message === "completion-reply-image-invalid-upload" ||
6214
+ error.message === "completion-reply-image-disabled"
6215
+ ) {
6216
+ return writeJson(res, 400, { error: error.message });
6217
+ }
6131
6218
  if (error.message === "completion-reply-unavailable") {
6132
6219
  return writeJson(res, 409, { error: error.message });
6133
6220
  }
@@ -6766,6 +6853,20 @@ async function parseFormBody(req) {
6766
6853
  });
6767
6854
  }
6768
6855
 
6856
+ async function parseMultipartBody(req) {
6857
+ const contentLength = Number(req.headers["content-length"]) || 0;
6858
+ if (contentLength > 32 * 1024 * 1024) {
6859
+ throw new Error("request-body-too-large");
6860
+ }
6861
+ const request = new Request("http://localhost/upload", {
6862
+ method: req.method || "POST",
6863
+ headers: req.headers,
6864
+ body: req,
6865
+ duplex: "half",
6866
+ });
6867
+ return request.formData();
6868
+ }
6869
+
6769
6870
  async function parseJsonBody(req) {
6770
6871
  return new Promise((resolve, reject) => {
6771
6872
  let body = "";
@@ -6788,6 +6889,94 @@ async function parseJsonBody(req) {
6788
6889
  });
6789
6890
  }
6790
6891
 
6892
+ function guessUploadExtension(fileName, mimeType) {
6893
+ const explicitExtension = path.extname(cleanText(fileName || ""));
6894
+ if (explicitExtension) {
6895
+ return explicitExtension.toLowerCase();
6896
+ }
6897
+ const normalizedMimeType = cleanText(mimeType || "").toLowerCase();
6898
+ const knownExtensions = {
6899
+ "image/jpeg": ".jpg",
6900
+ "image/png": ".png",
6901
+ "image/webp": ".webp",
6902
+ "image/gif": ".gif",
6903
+ "image/heic": ".heic",
6904
+ "image/heif": ".heif",
6905
+ };
6906
+ return knownExtensions[normalizedMimeType] || ".img";
6907
+ }
6908
+
6909
+ async function cleanupExpiredCompletionReplyUploads(config) {
6910
+ try {
6911
+ const entries = await fs.readdir(config.replyUploadsDir, { withFileTypes: true });
6912
+ const cutoffMs = Date.now() - config.completionReplyUploadTtlMs;
6913
+ await Promise.all(entries.map(async (entry) => {
6914
+ if (!entry.isFile()) {
6915
+ return;
6916
+ }
6917
+ const filePath = path.join(config.replyUploadsDir, entry.name);
6918
+ try {
6919
+ const stat = await fs.stat(filePath);
6920
+ if (Number(stat.mtimeMs) < cutoffMs) {
6921
+ await fs.rm(filePath, { force: true });
6922
+ }
6923
+ } catch {
6924
+ // Ignore best-effort cleanup errors.
6925
+ }
6926
+ }));
6927
+ } catch {
6928
+ // Ignore missing upload dir.
6929
+ }
6930
+ }
6931
+
6932
+ async function stageCompletionReplyImages(config, req) {
6933
+ const formData = await parseMultipartBody(req);
6934
+ const files = formData
6935
+ .getAll("image")
6936
+ .filter((value) => typeof File !== "undefined" && value instanceof File);
6937
+
6938
+ if (files.length > 0) {
6939
+ throw new Error("completion-reply-image-disabled");
6940
+ }
6941
+
6942
+ if (files.length > MAX_COMPLETION_REPLY_IMAGE_COUNT) {
6943
+ throw new Error("completion-reply-image-limit");
6944
+ }
6945
+
6946
+ await cleanupExpiredCompletionReplyUploads(config);
6947
+ await fs.mkdir(config.replyUploadsDir, { recursive: true });
6948
+
6949
+ const localImagePaths = [];
6950
+ for (const file of files) {
6951
+ const mimeType = cleanText(file.type || "").toLowerCase();
6952
+ if (!mimeType.startsWith("image/")) {
6953
+ throw new Error("completion-reply-image-invalid-type");
6954
+ }
6955
+ if (!Number.isFinite(file.size) || file.size <= 0) {
6956
+ throw new Error("completion-reply-image-invalid-upload");
6957
+ }
6958
+ if (file.size > config.completionReplyImageMaxBytes) {
6959
+ throw new Error("completion-reply-image-too-large");
6960
+ }
6961
+
6962
+ const extension = guessUploadExtension(file.name, mimeType);
6963
+ const stagedFilePath = path.join(
6964
+ config.replyUploadsDir,
6965
+ `${Date.now()}-${crypto.randomUUID()}${extension}`
6966
+ );
6967
+ const buffer = Buffer.from(await file.arrayBuffer());
6968
+ await fs.writeFile(stagedFilePath, buffer, { mode: 0o600 });
6969
+ localImagePaths.push(stagedFilePath);
6970
+ }
6971
+
6972
+ return {
6973
+ text: cleanText(formData.get("text") ?? ""),
6974
+ planMode: String(formData.get("planMode") ?? "") === "true",
6975
+ force: String(formData.get("force") ?? "") === "true",
6976
+ localImagePaths,
6977
+ };
6978
+ }
6979
+
6791
6980
  function isLoopbackRequest(req) {
6792
6981
  const remoteAddress = cleanText(req.socket?.remoteAddress ?? "");
6793
6982
  return (
@@ -7727,6 +7916,7 @@ function isLoopbackHostname(value) {
7727
7916
 
7728
7917
  function buildConfig(cli) {
7729
7918
  const codexHome = resolvePath(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"));
7919
+ const stateFile = resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json"));
7730
7920
  return {
7731
7921
  dryRun: cli.dryRun || truthy(process.env.DRY_RUN),
7732
7922
  once: cli.once,
@@ -7740,7 +7930,8 @@ function buildConfig(cli) {
7740
7930
  sessionIndexFile: resolvePath(process.env.SESSION_INDEX_FILE || path.join(codexHome, "session_index.jsonl")),
7741
7931
  historyFile: resolvePath(process.env.HISTORY_FILE || path.join(codexHome, "history.jsonl")),
7742
7932
  codexLogsDbFile: resolvePath(process.env.CODEX_LOGS_DB_FILE || ""),
7743
- stateFile: resolvePath(process.env.STATE_FILE || path.join(workspaceRoot, ".viveworker-state.json")),
7933
+ stateFile,
7934
+ replyUploadsDir: resolvePath(process.env.REPLY_UPLOADS_DIR || path.join(path.dirname(stateFile), "uploads")),
7744
7935
  pollIntervalMs: numberEnv("POLL_INTERVAL_MS", 2500),
7745
7936
  replaySeconds: numberEnv("REPLAY_SECONDS", 300),
7746
7937
  sessionIndexRefreshMs: numberEnv("SESSION_INDEX_REFRESH_MS", 30000),
@@ -7794,6 +7985,14 @@ function buildConfig(cli) {
7794
7985
  ipcReconnectMs: numberEnv("IPC_RECONNECT_MS", 1500),
7795
7986
  ipcRequestTimeoutMs: numberEnv("IPC_REQUEST_TIMEOUT_MS", 12000),
7796
7987
  choicePageSize: numberEnv("CHOICE_PAGE_SIZE", 5),
7988
+ completionReplyImageMaxBytes: numberEnv(
7989
+ "COMPLETION_REPLY_IMAGE_MAX_BYTES",
7990
+ DEFAULT_COMPLETION_REPLY_IMAGE_MAX_BYTES
7991
+ ),
7992
+ completionReplyUploadTtlMs: numberEnv(
7993
+ "COMPLETION_REPLY_UPLOAD_TTL_MS",
7994
+ DEFAULT_COMPLETION_REPLY_UPLOAD_TTL_MS
7995
+ ),
7797
7996
  deviceTrustTtlMs: numberEnv("DEVICE_TRUST_TTL_MS", DEFAULT_DEVICE_TRUST_TTL_MS),
7798
7997
  sessionTtlMs: numberEnv("SESSION_TTL_MS", 30 * 24 * 60 * 60 * 1000),
7799
7998
  pairingCode: process.env.PAIRING_CODE || "",
@@ -7907,9 +8106,7 @@ function loadEnvFile(filePath) {
7907
8106
  if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) {
7908
8107
  value = value.slice(1, -1);
7909
8108
  }
7910
- if (!process.env[key]) {
7911
- process.env[key] = value;
7912
- }
8109
+ process.env[key] = value;
7913
8110
  }
7914
8111
  } catch {
7915
8112
  // Optional env file.
@@ -200,7 +200,14 @@ async function runSetup(cliOptions) {
200
200
 
201
201
  progress.update("cli.setup.progress.health");
202
202
  const healthy = await waitForHealth(buildLoopbackHealthUrl(publicBaseUrl));
203
- progress.done(healthy ? "cli.setup.complete" : "cli.setup.completePending");
203
+ const pairingReady = healthy
204
+ ? await waitForExpectedPairing(publicBaseUrl, pairToken)
205
+ : false;
206
+ progress.done(healthy && pairingReady ? "cli.setup.complete" : "cli.setup.completePending");
207
+ if (healthy && !pairingReady) {
208
+ console.log("");
209
+ console.log(t(locale, "cli.setup.warning.stalePairingServer", { port }));
210
+ }
204
211
 
205
212
  const pairPath = `/app?pairToken=${encodeURIComponent(pairToken)}`;
206
213
  const mkcertRootCaFile = resolvePath(
@@ -258,7 +265,7 @@ async function runSetup(cliOptions) {
258
265
  if (allowInsecureHttpLan) {
259
266
  console.log(t(locale, "cli.setup.warning.insecureHttpLan"));
260
267
  }
261
- if (canShowCaDownload && !cliOptions.installMkcert) {
268
+ if (canShowCaDownload && !cliOptions.installMkcert && !cliOptions.pair) {
262
269
  console.log(t(locale, "cli.setup.caDownloadLocal", { url: caDownloadLocalUrl }));
263
270
  console.log(t(locale, "cli.setup.caDownloadIp", { url: caDownloadIpUrl }));
264
271
  }
@@ -273,7 +280,7 @@ async function runSetup(cliOptions) {
273
280
  console.log("");
274
281
  console.log(t(locale, "cli.setup.qrPairing"));
275
282
  await printQrCode(`${publicBaseUrl}${pairPath}`);
276
- if (canShowCaDownload && !cliOptions.installMkcert) {
283
+ if (canShowCaDownload && !cliOptions.installMkcert && !cliOptions.pair) {
277
284
  console.log("");
278
285
  console.log(t(locale, "cli.setup.qrCaDownload"));
279
286
  await printQrCode(caDownloadIpUrl);
@@ -309,7 +316,14 @@ async function runStart(cliOptions) {
309
316
  await execCommand(["launchctl", "kickstart", "-k", `gui/${process.getuid()}/${defaultLabel}`]);
310
317
  progress.update("cli.start.progress.health");
311
318
  const healthy = await waitForHealth(healthUrl);
312
- progress.done(healthy ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
319
+ const pairingReady = healthy && rotatedPairing.rotated
320
+ ? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
321
+ : true;
322
+ progress.done(healthy && pairingReady ? "cli.start.launchdStarted" : "cli.start.launchdStartedPending");
323
+ if (healthy && !pairingReady) {
324
+ console.log("");
325
+ console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
326
+ }
313
327
  if (rotatedPairing.rotated) {
314
328
  await printPairingInfo(locale, config);
315
329
  }
@@ -324,7 +338,14 @@ async function runStart(cliOptions) {
324
338
  });
325
339
  progress.update("cli.start.progress.health");
326
340
  const healthy = await waitForHealth(healthUrl);
327
- progress.done(healthy ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
341
+ const pairingReady = healthy && rotatedPairing.rotated
342
+ ? await waitForExpectedPairing(config.NATIVE_APPROVAL_SERVER_PUBLIC_BASE_URL || "", rotatedPairing.pairingToken)
343
+ : true;
344
+ progress.done(healthy && pairingReady ? "cli.start.bridgeStarted" : "cli.start.bridgeStartedPending");
345
+ if (healthy && !pairingReady) {
346
+ console.log("");
347
+ console.log(t(locale, "cli.setup.warning.stalePairingServer", { port: config.NATIVE_APPROVAL_SERVER_PORT || defaultServerPort }));
348
+ }
328
349
  if (rotatedPairing.rotated) {
329
350
  await printPairingInfo(locale, config);
330
351
  }
@@ -982,6 +1003,33 @@ function buildLoopbackHealthUrl(baseUrl) {
982
1003
  }
983
1004
  }
984
1005
 
1006
+ function buildLoopbackUrl(baseUrl, pathname, searchParams = null) {
1007
+ if (!baseUrl) {
1008
+ return "";
1009
+ }
1010
+ try {
1011
+ const url = new URL(baseUrl);
1012
+ url.hostname = "127.0.0.1";
1013
+ url.pathname = pathname;
1014
+ url.search = "";
1015
+ url.hash = "";
1016
+ if (searchParams && Object.keys(searchParams).length > 0) {
1017
+ const params = new URLSearchParams();
1018
+ for (const [key, value] of Object.entries(searchParams)) {
1019
+ if (value == null || value === "") {
1020
+ continue;
1021
+ }
1022
+ params.set(key, String(value));
1023
+ }
1024
+ const serialized = params.toString();
1025
+ url.search = serialized ? `?${serialized}` : "";
1026
+ }
1027
+ return url.toString();
1028
+ } catch {
1029
+ return "";
1030
+ }
1031
+ }
1032
+
985
1033
  function sleep(ms) {
986
1034
  return new Promise((resolve) => setTimeout(resolve, ms));
987
1035
  }
@@ -1105,6 +1153,34 @@ async function waitForHealth(url, { attempts = 8, intervalMs = 500 } = {}) {
1105
1153
  return false;
1106
1154
  }
1107
1155
 
1156
+ async function waitForExpectedPairing(baseUrl, pairToken, { attempts = 8, intervalMs = 500 } = {}) {
1157
+ const token = String(pairToken || "").trim();
1158
+ const manifestUrl = buildLoopbackUrl(baseUrl, "/manifest.webmanifest", { pairToken: token });
1159
+ const expectedStartUrl = `/app?pairToken=${encodeURIComponent(token)}`;
1160
+ if (!token || !manifestUrl) {
1161
+ return false;
1162
+ }
1163
+
1164
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1165
+ const result = await execCommand(buildHealthCheckArgs(manifestUrl), { ignoreError: true });
1166
+ if (result.ok) {
1167
+ try {
1168
+ const payload = JSON.parse(result.stdout);
1169
+ if (String(payload?.start_url || "").trim() === expectedStartUrl) {
1170
+ return true;
1171
+ }
1172
+ } catch {
1173
+ // Keep retrying while the new bridge instance comes up.
1174
+ }
1175
+ }
1176
+ if (attempt < attempts - 1) {
1177
+ await sleep(intervalMs);
1178
+ }
1179
+ }
1180
+
1181
+ return false;
1182
+ }
1183
+
1108
1184
  function truthyString(value) {
1109
1185
  return /^(1|true|yes|on)$/iu.test(String(value || "").trim());
1110
1186
  }
package/web/app.css CHANGED
@@ -1541,6 +1541,90 @@ code {
1541
1541
  color: rgba(205, 220, 231, 0.42);
1542
1542
  }
1543
1543
 
1544
+ .reply-attachment-field {
1545
+ display: grid;
1546
+ gap: 0.55rem;
1547
+ }
1548
+
1549
+ .reply-attachment-field__header {
1550
+ display: flex;
1551
+ align-items: center;
1552
+ justify-content: space-between;
1553
+ gap: 0.75rem;
1554
+ }
1555
+
1556
+ .reply-attachment-picker {
1557
+ display: grid;
1558
+ gap: 0.18rem;
1559
+ padding: 0.88rem 0.95rem;
1560
+ border-radius: 18px;
1561
+ border: 1px dashed rgba(156, 181, 197, 0.24);
1562
+ background: rgba(255, 255, 255, 0.03);
1563
+ cursor: pointer;
1564
+ }
1565
+
1566
+ .reply-attachment-picker__input {
1567
+ position: absolute;
1568
+ opacity: 0;
1569
+ pointer-events: none;
1570
+ }
1571
+
1572
+ .reply-attachment-picker__label {
1573
+ font-size: 0.93rem;
1574
+ font-weight: 700;
1575
+ color: #eef8ff;
1576
+ }
1577
+
1578
+ .reply-attachment-picker__hint {
1579
+ font-size: 0.82rem;
1580
+ color: var(--muted);
1581
+ line-height: 1.42;
1582
+ }
1583
+
1584
+ .reply-image-preview {
1585
+ display: grid;
1586
+ grid-template-columns: 4.5rem minmax(0, 1fr);
1587
+ gap: 0.8rem;
1588
+ align-items: center;
1589
+ padding: 0.78rem 0.85rem;
1590
+ border-radius: 18px;
1591
+ border: 1px solid rgba(156, 181, 197, 0.12);
1592
+ background: rgba(255, 255, 255, 0.04);
1593
+ }
1594
+
1595
+ .reply-image-preview__image {
1596
+ width: 4.5rem;
1597
+ height: 4.5rem;
1598
+ object-fit: cover;
1599
+ border-radius: 14px;
1600
+ border: 1px solid rgba(255, 255, 255, 0.08);
1601
+ background: rgba(6, 10, 14, 0.92);
1602
+ }
1603
+
1604
+ .reply-image-preview__copy {
1605
+ min-width: 0;
1606
+ display: grid;
1607
+ gap: 0.14rem;
1608
+ }
1609
+
1610
+ .reply-image-preview__name,
1611
+ .reply-image-preview__meta {
1612
+ margin: 0;
1613
+ }
1614
+
1615
+ .reply-image-preview__name {
1616
+ color: #eef8ff;
1617
+ font-size: 0.91rem;
1618
+ font-weight: 600;
1619
+ line-height: 1.35;
1620
+ overflow-wrap: anywhere;
1621
+ }
1622
+
1623
+ .reply-image-preview__meta {
1624
+ color: var(--muted);
1625
+ font-size: 0.8rem;
1626
+ }
1627
+
1544
1628
  .reply-mode-switch {
1545
1629
  display: grid;
1546
1630
  grid-template-columns: auto auto minmax(0, 1fr);
package/web/app.js CHANGED
@@ -87,9 +87,12 @@ async function boot() {
87
87
 
88
88
  await refreshSession();
89
89
 
90
- if (!state.session?.authenticated && initialPairToken) {
90
+ if (!state.session?.authenticated && initialPairToken && shouldAutoPairFromBootstrapToken()) {
91
91
  try {
92
- await pair({ token: initialPairToken });
92
+ await pair({
93
+ token: initialPairToken,
94
+ temporary: shouldUseTemporaryBootstrapPairing(),
95
+ });
93
96
  } catch (error) {
94
97
  state.pairError = error.message || String(error);
95
98
  }
@@ -480,6 +483,7 @@ function syncCompletedThreadFilter() {
480
483
  }
481
484
 
482
485
  function renderPair() {
486
+ const shouldInstallFromHomeScreen = Boolean(initialPairToken) && !shouldAutoPairFromBootstrapToken();
483
487
  app.innerHTML = `
484
488
  <main class="onboarding-shell">
485
489
  <section class="onboarding-card">
@@ -488,6 +492,7 @@ function renderPair() {
488
492
  <p class="hero-copy">${escapeHtml(L("pair.copy"))}</p>
489
493
  ${state.pairNotice ? `<p class="inline-alert inline-alert--success">${escapeHtml(state.pairNotice)}</p>` : ""}
490
494
  ${state.pairError ? `<p class="inline-alert inline-alert--danger">${escapeHtml(state.pairError)}</p>` : ""}
495
+ ${shouldInstallFromHomeScreen ? `<p class="inline-alert inline-alert--warning">${escapeHtml(L("pair.installFromHomeScreen"))}</p>` : ""}
491
496
  <form id="pair-form" class="pair-form">
492
497
  <label class="field">
493
498
  <span class="field-label">${escapeHtml(L("pair.codeLabel"))}</span>
@@ -530,7 +535,9 @@ function renderPair() {
530
535
 
531
536
  async function pair(payload) {
532
537
  const result = await apiPost("/api/session/pair", payload);
533
- syncPairingTokenState("");
538
+ if (result?.temporaryPairing !== true) {
539
+ syncPairingTokenState("");
540
+ }
534
541
  return result;
535
542
  }
536
543
 
@@ -555,6 +562,7 @@ function resetAuthenticatedState() {
555
562
  state.detailLoadingItem = null;
556
563
  state.detailOpen = false;
557
564
  state.choiceLocalDrafts = {};
565
+ clearAllCompletionReplyDrafts();
558
566
  state.completionReplyDrafts = {};
559
567
  state.settingsSubpage = "";
560
568
  state.settingsScrollState = null;
@@ -765,11 +773,70 @@ function normalizeReplyMode(value) {
765
773
  return normalizeClientText(value).toLowerCase() === "plan" ? "plan" : "default";
766
774
  }
767
775
 
776
+ const COMPLETION_REPLY_IMAGE_SUPPORT = false;
777
+
778
+ function normalizeCompletionReplyAttachment(value) {
779
+ if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
780
+ return null;
781
+ }
782
+ if (!value || typeof value !== "object") {
783
+ return null;
784
+ }
785
+ const file = typeof File !== "undefined" && value.file instanceof File ? value.file : null;
786
+ const name = normalizeClientText(value.name || file?.name || "");
787
+ const type = normalizeClientText(value.type || file?.type || "");
788
+ const size = Number(value.size ?? file?.size) || 0;
789
+ const previewUrl = normalizeClientText(value.previewUrl || "");
790
+ if (!file || !name || !type.startsWith("image/") || size <= 0) {
791
+ return null;
792
+ }
793
+ return {
794
+ file,
795
+ name,
796
+ type,
797
+ size,
798
+ previewUrl,
799
+ };
800
+ }
801
+
802
+ function createCompletionReplyAttachment(file) {
803
+ if (!COMPLETION_REPLY_IMAGE_SUPPORT) {
804
+ return null;
805
+ }
806
+ if (!(typeof File !== "undefined" && file instanceof File)) {
807
+ return null;
808
+ }
809
+ if (!normalizeClientText(file.type).startsWith("image/")) {
810
+ return null;
811
+ }
812
+ return normalizeCompletionReplyAttachment({
813
+ file,
814
+ name: file.name,
815
+ type: file.type,
816
+ size: file.size,
817
+ previewUrl: typeof URL !== "undefined" && typeof URL.createObjectURL === "function"
818
+ ? URL.createObjectURL(file)
819
+ : "",
820
+ });
821
+ }
822
+
823
+ function releaseCompletionReplyAttachment(attachment) {
824
+ if (!attachment?.previewUrl || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") {
825
+ return;
826
+ }
827
+ try {
828
+ URL.revokeObjectURL(attachment.previewUrl);
829
+ } catch {
830
+ // Ignore best-effort object URL cleanup errors.
831
+ }
832
+ }
833
+
768
834
  function getCompletionReplyDraft(token) {
769
835
  if (!token) {
770
836
  return {
771
837
  text: "",
772
838
  sentText: "",
839
+ attachment: null,
773
840
  mode: "default",
774
841
  notice: "",
775
842
  error: "",
@@ -784,6 +851,7 @@ function getCompletionReplyDraft(token) {
784
851
  return {
785
852
  text: String(draft.text ?? ""),
786
853
  sentText: normalizeClientText(draft.sentText ?? ""),
854
+ attachment: normalizeCompletionReplyAttachment(draft.attachment),
787
855
  mode: normalizeReplyMode(draft.mode),
788
856
  notice: normalizeClientText(draft.notice),
789
857
  error: normalizeClientText(draft.error),
@@ -815,13 +883,22 @@ function setCompletionReplyDraft(token, partialDraft) {
815
883
  if (!token) {
816
884
  return;
817
885
  }
886
+ const previousStoredDraft = state.completionReplyDrafts?.[token] || {};
887
+ const previousAttachment = normalizeCompletionReplyAttachment(previousStoredDraft.attachment);
818
888
  const nextDraft = {
819
889
  ...getCompletionReplyDraft(token),
820
890
  ...(partialDraft || {}),
821
891
  };
892
+ const nextAttachment = Object.prototype.hasOwnProperty.call(partialDraft || {}, "attachment")
893
+ ? normalizeCompletionReplyAttachment(partialDraft?.attachment)
894
+ : normalizeCompletionReplyAttachment(nextDraft.attachment);
895
+ if (previousAttachment?.previewUrl && previousAttachment.previewUrl !== nextAttachment?.previewUrl) {
896
+ releaseCompletionReplyAttachment(previousAttachment);
897
+ }
822
898
  state.completionReplyDrafts[token] = {
823
899
  text: String(nextDraft.text ?? ""),
824
900
  sentText: normalizeClientText(nextDraft.sentText ?? ""),
901
+ attachment: nextAttachment,
825
902
  mode: normalizeReplyMode(nextDraft.mode),
826
903
  notice: normalizeClientText(nextDraft.notice),
827
904
  error: normalizeClientText(nextDraft.error),
@@ -836,9 +913,16 @@ function clearCompletionReplyDraft(token) {
836
913
  if (!token || !state.completionReplyDrafts?.[token]) {
837
914
  return;
838
915
  }
916
+ releaseCompletionReplyAttachment(state.completionReplyDrafts[token]?.attachment);
839
917
  delete state.completionReplyDrafts[token];
840
918
  }
841
919
 
920
+ function clearAllCompletionReplyDrafts() {
921
+ for (const token of Object.keys(state.completionReplyDrafts || {})) {
922
+ clearCompletionReplyDraft(token);
923
+ }
924
+ }
925
+
842
926
  function syncCompletionReplyComposerLiveState(replyForm, draft) {
843
927
  if (!replyForm) {
844
928
  return;
@@ -2210,6 +2294,8 @@ function renderCompletionReplyComposer(detail, options = {}) {
2210
2294
  const warningTimestamp = draft.warning?.createdAtMs ? formatTimelineTimestamp(draft.warning.createdAtMs) : "";
2211
2295
  const showCollapsedState =
2212
2296
  draft.collapsedAfterSend && Boolean(draft.notice) && !draft.error && !draft.warning && !draft.sending;
2297
+ const attachmentName = draft.attachment?.name ? escapeHtml(draft.attachment.name) : "";
2298
+ const attachmentPreviewUrl = draft.attachment?.previewUrl ? escapeHtml(draft.attachment.previewUrl) : "";
2213
2299
 
2214
2300
  return `
2215
2301
  <section class="detail-card detail-card--reply ${options.mobile ? "detail-card--mobile" : ""}">
@@ -2275,6 +2361,55 @@ function renderCompletionReplyComposer(detail, options = {}) {
2275
2361
  data-reply-token="${escapeHtml(detail.token)}"
2276
2362
  >${escapeHtml(draft.text)}</textarea>
2277
2363
  </label>
2364
+ ${
2365
+ detail.reply?.supportsImages
2366
+ ? `
2367
+ <div class="reply-attachment-field">
2368
+ <div class="reply-attachment-field__header">
2369
+ <span class="field-label">${escapeHtml(L("reply.imageLabel"))}</span>
2370
+ ${
2371
+ draft.attachment
2372
+ ? `
2373
+ <button
2374
+ class="secondary secondary--compact"
2375
+ type="button"
2376
+ data-reply-image-remove
2377
+ data-reply-token="${escapeHtml(detail.token)}"
2378
+ >
2379
+ ${escapeHtml(L("reply.imageRemove"))}
2380
+ </button>
2381
+ `
2382
+ : ""
2383
+ }
2384
+ </div>
2385
+ <label class="reply-attachment-picker">
2386
+ <input
2387
+ class="reply-attachment-picker__input"
2388
+ type="file"
2389
+ accept="image/*"
2390
+ data-reply-image-input
2391
+ data-reply-token="${escapeHtml(detail.token)}"
2392
+ >
2393
+ <span class="reply-attachment-picker__label">${escapeHtml(L(draft.attachment ? "reply.imageReplace" : "reply.imageAdd"))}</span>
2394
+ <span class="reply-attachment-picker__hint">${escapeHtml(L("reply.imageHint"))}</span>
2395
+ </label>
2396
+ ${
2397
+ draft.attachment
2398
+ ? `
2399
+ <div class="reply-image-preview">
2400
+ <img class="reply-image-preview__image" src="${attachmentPreviewUrl}" alt="${attachmentName}">
2401
+ <div class="reply-image-preview__copy">
2402
+ <p class="reply-image-preview__name">${attachmentName}</p>
2403
+ <p class="reply-image-preview__meta">${escapeHtml(L("reply.imageAttached"))}</p>
2404
+ </div>
2405
+ </div>
2406
+ `
2407
+ : ""
2408
+ }
2409
+ </div>
2410
+ `
2411
+ : ""
2412
+ }
2278
2413
  ${
2279
2414
  detail.reply?.supportsPlanMode
2280
2415
  ? `
@@ -2841,6 +2976,46 @@ function bindShellInteractions() {
2841
2976
  });
2842
2977
  }
2843
2978
 
2979
+ for (const input of document.querySelectorAll("[data-reply-image-input][data-reply-token]")) {
2980
+ input.addEventListener("change", async () => {
2981
+ const token = input.dataset.replyToken || "";
2982
+ const [file] = Array.from(input.files || []);
2983
+ const nextAttachment = createCompletionReplyAttachment(file);
2984
+ if (!nextAttachment && file) {
2985
+ setCompletionReplyDraft(token, {
2986
+ error: L("error.completionReplyImageInvalidType"),
2987
+ notice: "",
2988
+ warning: null,
2989
+ confirmOverride: false,
2990
+ });
2991
+ await renderShell();
2992
+ return;
2993
+ }
2994
+ setCompletionReplyDraft(token, {
2995
+ attachment: nextAttachment,
2996
+ notice: "",
2997
+ error: "",
2998
+ warning: null,
2999
+ confirmOverride: false,
3000
+ });
3001
+ await renderShell();
3002
+ });
3003
+ }
3004
+
3005
+ for (const button of document.querySelectorAll("[data-reply-image-remove][data-reply-token]")) {
3006
+ button.addEventListener("click", async () => {
3007
+ const token = button.dataset.replyToken || "";
3008
+ setCompletionReplyDraft(token, {
3009
+ attachment: null,
3010
+ notice: "",
3011
+ error: "",
3012
+ warning: null,
3013
+ confirmOverride: false,
3014
+ });
3015
+ await renderShell();
3016
+ });
3017
+ }
3018
+
2844
3019
  for (const button of document.querySelectorAll("[data-open-logout-confirm]")) {
2845
3020
  button.addEventListener("click", async () => {
2846
3021
  state.logoutConfirmOpen = true;
@@ -3010,14 +3185,18 @@ function bindShellInteractions() {
3010
3185
  await renderShell();
3011
3186
 
3012
3187
  try {
3013
- await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, {
3014
- text,
3015
- planMode: draft.mode === "plan",
3016
- force: draft.confirmOverride === true,
3017
- });
3188
+ const requestBody = new FormData();
3189
+ requestBody.set("text", text);
3190
+ requestBody.set("planMode", draft.mode === "plan" ? "true" : "false");
3191
+ requestBody.set("force", draft.confirmOverride === true ? "true" : "false");
3192
+ if (COMPLETION_REPLY_IMAGE_SUPPORT && draft.attachment?.file) {
3193
+ requestBody.append("image", draft.attachment.file, draft.attachment.name || draft.attachment.file.name);
3194
+ }
3195
+ await apiPost(`/api/items/completion/${encodeURIComponent(token)}/reply`, requestBody);
3018
3196
  setCompletionReplyDraft(token, {
3019
3197
  text: "",
3020
3198
  sentText: text,
3199
+ attachment: null,
3021
3200
  mode: draft.mode,
3022
3201
  sending: false,
3023
3202
  error: "",
@@ -3032,6 +3211,7 @@ function bindShellInteractions() {
3032
3211
  setCompletionReplyDraft(token, {
3033
3212
  text,
3034
3213
  sentText: "",
3214
+ attachment: draft.attachment,
3035
3215
  mode: draft.mode,
3036
3216
  sending: false,
3037
3217
  notice: "",
@@ -3046,6 +3226,7 @@ function bindShellInteractions() {
3046
3226
  setCompletionReplyDraft(token, {
3047
3227
  text,
3048
3228
  sentText: "",
3229
+ attachment: draft.attachment,
3049
3230
  mode: draft.mode,
3050
3231
  sending: false,
3051
3232
  notice: "",
@@ -3759,14 +3940,19 @@ async function apiGet(url) {
3759
3940
  }
3760
3941
 
3761
3942
  async function apiPost(url, body) {
3943
+ const isFormDataBody = typeof FormData !== "undefined" && body instanceof FormData;
3762
3944
  const response = await fetch(url, {
3763
3945
  method: "POST",
3764
3946
  credentials: "same-origin",
3765
- headers: {
3766
- "Content-Type": "application/json",
3767
- Accept: "application/json",
3768
- },
3769
- body: JSON.stringify(body || {}),
3947
+ headers: isFormDataBody
3948
+ ? {
3949
+ Accept: "application/json",
3950
+ }
3951
+ : {
3952
+ "Content-Type": "application/json",
3953
+ Accept: "application/json",
3954
+ },
3955
+ body: isFormDataBody ? body : JSON.stringify(body || {}),
3770
3956
  });
3771
3957
  if (!response.ok) {
3772
3958
  const errorInfo = await readError(response);
@@ -3809,6 +3995,11 @@ function localizeApiError(value) {
3809
3995
  "completion-reply-unavailable": "error.completionReplyUnavailable",
3810
3996
  "completion-reply-thread-advanced": "error.completionReplyThreadAdvanced",
3811
3997
  "completion-reply-empty": "error.completionReplyEmpty",
3998
+ "completion-reply-image-invalid-type": "error.completionReplyImageInvalidType",
3999
+ "completion-reply-image-too-large": "error.completionReplyImageTooLarge",
4000
+ "completion-reply-image-limit": "error.completionReplyImageLimit",
4001
+ "completion-reply-image-invalid-upload": "error.completionReplyImageInvalidUpload",
4002
+ "completion-reply-image-disabled": "error.completionReplyImageDisabled",
3812
4003
  "codex-ipc-not-connected": "error.codexIpcNotConnected",
3813
4004
  "approval-not-found": "error.approvalNotFound",
3814
4005
  "approval-already-handled": "error.approvalAlreadyHandled",
@@ -3883,12 +4074,23 @@ function syncPairingTokenState(pairToken) {
3883
4074
  }
3884
4075
 
3885
4076
  function desiredBootstrapPairingToken() {
3886
- if (state.session?.authenticated) {
4077
+ if (state.session?.authenticated && !state.session?.temporaryPairing) {
3887
4078
  return "";
3888
4079
  }
3889
4080
  return initialPairToken;
3890
4081
  }
3891
4082
 
4083
+ function shouldAutoPairFromBootstrapToken() {
4084
+ if (!initialPairToken) {
4085
+ return false;
4086
+ }
4087
+ return true;
4088
+ }
4089
+
4090
+ function shouldUseTemporaryBootstrapPairing() {
4091
+ return Boolean(initialPairToken) && !isStandaloneMode() && isProbablySafari();
4092
+ }
4093
+
3892
4094
  function urlBase64ToUint8Array(base64String) {
3893
4095
  const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
3894
4096
  const normalized = `${base64String}${padding}`.replace(/-/gu, "+").replace(/_/gu, "/");
package/web/i18n.js CHANGED
@@ -70,6 +70,8 @@ const translations = {
70
70
  "pair.codeLabel": "Pairing code",
71
71
  "pair.codePlaceholder": "Enter the pairing code",
72
72
  "pair.connect": "Connect this iPhone",
73
+ "pair.installFromHomeScreen":
74
+ "This pairing link is being kept for the Home Screen app. Add viveworker to your Home Screen from Safari, then open the new icon to finish pairing there.",
73
75
  "pair.helperTitle": "Add to Home Screen",
74
76
  "pair.helperCopy": "Install viveworker for the best mobile layout and Web Push support.",
75
77
  "banner.install.title": "Add viveworker to your Home Screen",
@@ -112,6 +114,12 @@ const translations = {
112
114
  "reply.copy": "Send a new message back into this Codex thread from the latest completed result.",
113
115
  "reply.fieldLabel": "Message",
114
116
  "reply.placeholder": "Ask Codex to continue, refine the result, or try a different approach.",
117
+ "reply.imageLabel": "Image",
118
+ "reply.imageAdd": "Add image",
119
+ "reply.imageReplace": "Replace image",
120
+ "reply.imageRemove": "Remove",
121
+ "reply.imageHint": "Attach one image from your iPhone and send it together with your message.",
122
+ "reply.imageAttached": "Attached image",
115
123
  "reply.send": "Send to Codex",
116
124
  "reply.sendConfirm": "Send anyway",
117
125
  "reply.sendAnother": "Send another message",
@@ -283,6 +291,11 @@ const translations = {
283
291
  "error.completionReplyUnavailable": "This completed item can no longer send a follow-up.",
284
292
  "error.completionReplyThreadAdvanced": "This thread already has newer messages.",
285
293
  "error.completionReplyEmpty": "Enter a message before sending it.",
294
+ "error.completionReplyImageInvalidType": "Choose an image file to attach.",
295
+ "error.completionReplyImageTooLarge": "That image is too large to send from this reply screen.",
296
+ "error.completionReplyImageLimit": "Attach one image at a time for now.",
297
+ "error.completionReplyImageInvalidUpload": "That image could not be uploaded.",
298
+ "error.completionReplyImageDisabled": "Image attachments are not available in this release.",
286
299
  "error.codexIpcNotConnected": "Codex desktop is not connected right now.",
287
300
  "error.approvalNotFound": "This approval is no longer available.",
288
301
  "error.approvalAlreadyHandled": "This approval was already handled.",
@@ -394,6 +407,8 @@ const translations = {
394
407
  "logout.action.removeDevice": "Log out and remove this device",
395
408
  "cli.setup.warning.insecureHttpLan":
396
409
  "Warning: insecure LAN HTTP is enabled. Session and device cookies can be exposed on the local network.",
410
+ "cli.setup.warning.stalePairingServer":
411
+ "Warning: viveworker is responding on port {port}, but it is still serving older pairing credentials. Another bridge process may still own this port. Stop the older instance, then rerun setup or start.",
397
412
  "cli.setup.qrPairing": "Pairing QR:",
398
413
  "cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
399
414
  "cli.setup.prompt.continueToApp": "Press Enter to continue to the viveworker app URL.",
@@ -509,6 +524,8 @@ const translations = {
509
524
  "pair.codeLabel": "ペアリングコード",
510
525
  "pair.codePlaceholder": "ペアリングコードを入力",
511
526
  "pair.connect": "この iPhone を接続",
527
+ "pair.installFromHomeScreen":
528
+ "この pairing link はホーム画面アプリ用に温存されています。Safari から viveworker をホーム画面に追加し、新しいアイコンを開いてそこで pairing を完了してください。",
512
529
  "pair.helperTitle": "ホーム画面に追加",
513
530
  "pair.helperCopy": "モバイル表示と Web Push を最適に使うには viveworker をインストールしてください。",
514
531
  "banner.install.title": "viveworker をホーム画面に追加",
@@ -550,6 +567,12 @@ const translations = {
550
567
  "reply.copy": "この完了結果を見ながら、同じ Codex スレッドに新しいメッセージを送れます。",
551
568
  "reply.fieldLabel": "メッセージ",
552
569
  "reply.placeholder": "続きを依頼したり、結果の修正や別案を Codex に伝えます。",
570
+ "reply.imageLabel": "画像",
571
+ "reply.imageAdd": "画像を追加",
572
+ "reply.imageReplace": "画像を差し替え",
573
+ "reply.imageRemove": "削除",
574
+ "reply.imageHint": "iPhone の画像を 1 枚だけ添付して、メッセージと一緒に送れます。",
575
+ "reply.imageAttached": "添付した画像",
553
576
  "reply.send": "Codex に送信",
554
577
  "reply.sendConfirm": "それでも送信",
555
578
  "reply.sendAnother": "もう一度送る",
@@ -721,6 +744,11 @@ const translations = {
721
744
  "error.completionReplyUnavailable": "この完了項目には、もう返信できません。",
722
745
  "error.completionReplyThreadAdvanced": "このスレッドには、すでに新しいメッセージがあります。",
723
746
  "error.completionReplyEmpty": "送信前にメッセージを入力してください。",
747
+ "error.completionReplyImageInvalidType": "添付できるのは画像ファイルのみです。",
748
+ "error.completionReplyImageTooLarge": "この画面から送るには画像が大きすぎます。",
749
+ "error.completionReplyImageLimit": "いまは画像を 1 枚ずつ添付してください。",
750
+ "error.completionReplyImageInvalidUpload": "この画像はアップロードできませんでした。",
751
+ "error.completionReplyImageDisabled": "このリリースでは画像添付はまだ利用できません。",
724
752
  "error.codexIpcNotConnected": "いまは Codex desktop に接続できていません。",
725
753
  "error.approvalNotFound": "この承認はもう利用できません。",
726
754
  "error.approvalAlreadyHandled": "この承認はすでに処理済みです。",
@@ -832,6 +860,8 @@ const translations = {
832
860
  "logout.action.removeDevice": "ログアウトしてこの端末を削除",
833
861
  "cli.setup.warning.insecureHttpLan":
834
862
  "警告: insecure LAN HTTP が有効です。session / device cookie がローカルネットワーク上で露出する可能性があります。",
863
+ "cli.setup.warning.stalePairingServer":
864
+ "警告: port {port} の viveworker は応答していますが、古い pairing 情報を配信したままです。別の bridge process がこの port を使っている可能性があります。古い instance を停止してから setup または start をやり直してください。",
835
865
  "cli.setup.qrPairing": "Pairing QR:",
836
866
  "cli.setup.qrCaDownload": "rootCA.pem download QR (IP):",
837
867
  "cli.setup.prompt.continueToApp": "viveworker の接続先 URL を表示するには Enter を押してください。",
package/web/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "viveworker-v2";
1
+ const CACHE_NAME = "viveworker-v5";
2
2
  const APP_ASSETS = ["/app.css", "/app.js", "/i18n.js"];
3
3
  const APP_ROUTES = new Set(["/", "/app", "/app/"]);
4
4
  const CACHED_PATHS = new Set(APP_ASSETS);