oysterun 0.1.1-beta.2 → 0.1.1-beta.4

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.
@@ -7587,6 +7587,62 @@
7587
7587
  </div>
7588
7588
  </div>
7589
7589
  </section>
7590
+ <section class="table-panel">
7591
+ <div class="table-title">
7592
+ <div class="table-title-main">
7593
+ <strong>Oysterun Update</strong>
7594
+ <div class="table-title-copy">
7595
+ Check and update the installed Host package.
7596
+ </div>
7597
+ </div>
7598
+ </div>
7599
+ <div class="kv-table">
7600
+ <div class="kv-label">Current Version</div>
7601
+ <div class="kv-value">
7602
+ <div class="mono-path" id="host-update-current">
7603
+ --
7604
+ </div>
7605
+ </div>
7606
+ <div class="kv-label">Channel</div>
7607
+ <div class="kv-value">
7608
+ <div class="mono-path" id="host-update-channel">
7609
+ --
7610
+ </div>
7611
+ </div>
7612
+ <div class="kv-label">Latest Version</div>
7613
+ <div class="kv-value">
7614
+ <div class="value-stack">
7615
+ <div class="mono-path" id="host-update-latest">
7616
+ --
7617
+ </div>
7618
+ <div class="field-hint" id="host-update-status">
7619
+ Click Check for updates to query npm.
7620
+ </div>
7621
+ </div>
7622
+ </div>
7623
+ <div class="kv-label">Action</div>
7624
+ <div class="kv-value">
7625
+ <div class="control-cell">
7626
+ <button
7627
+ class="btn-primary"
7628
+ type="button"
7629
+ id="host-update-check-btn"
7630
+ onClick=${call("checkOysterunUpdate")}
7631
+ >
7632
+ Check for updates
7633
+ </button>
7634
+ <button
7635
+ class="btn-primary"
7636
+ type="button"
7637
+ id="host-update-run-btn"
7638
+ onClick=${call("runOysterunUpdate")}
7639
+ >
7640
+ Update now
7641
+ </button>
7642
+ </div>
7643
+ </div>
7644
+ </div>
7645
+ </section>
7590
7646
  <section
7591
7647
  class="table-panel browser-notification-card"
7592
7648
  id="host-notification-card"
@@ -11265,6 +11321,11 @@ Waiting for Claude login output…</pre
11265
11321
  loginQrError: "",
11266
11322
  restartLoading: false,
11267
11323
  restartStatus: "",
11324
+ update: null,
11325
+ updateChecking: false,
11326
+ updateRunning: false,
11327
+ updateStatus: "",
11328
+ updateError: "",
11268
11329
  localUrl: "",
11269
11330
  defaultBrowsePath: "",
11270
11331
  resolvedDefaultBrowsePath: "",
@@ -26515,6 +26576,14 @@ Waiting for Claude login output…</pre
26515
26576
  loginQrLoading: false,
26516
26577
  loginQrError: "",
26517
26578
  restartLoading: false,
26579
+ update:
26580
+ data.update && typeof data.update === "object"
26581
+ ? data.update
26582
+ : hostPreferencesState.update,
26583
+ updateChecking: false,
26584
+ updateRunning: false,
26585
+ updateStatus: "",
26586
+ updateError: "",
26518
26587
  localUrl: data.local_url || "",
26519
26588
  defaultBrowsePath: data.default_browse_path || "",
26520
26589
  resolvedDefaultBrowsePath:
@@ -28099,6 +28168,50 @@ Waiting for Claude login output…</pre
28099
28168
  ? "Restarting..."
28100
28169
  : "Restart Host";
28101
28170
  }
28171
+ const update = hostPreferencesState.update || {};
28172
+ setText("host-update-current", update.current_version || "--");
28173
+ setText("host-update-channel", update.channel || "--");
28174
+ setText("host-update-latest", update.latest_version || "--");
28175
+ let updateStatus =
28176
+ hostPreferencesState.updateError ||
28177
+ hostPreferencesState.updateStatus ||
28178
+ update.error ||
28179
+ "";
28180
+ if (!updateStatus) {
28181
+ if (update.update_available === true) {
28182
+ updateStatus = `Update available: ${update.latest_version}.`;
28183
+ } else if (update.latest_version) {
28184
+ updateStatus = "Oysterun is up to date for this channel.";
28185
+ } else if (update.update_supported === false) {
28186
+ updateStatus =
28187
+ "Update now is only available on the production npm Host stack.";
28188
+ } else {
28189
+ updateStatus = "Click Check for updates to query npm.";
28190
+ }
28191
+ }
28192
+ setText("host-update-status", updateStatus);
28193
+ const updateCheckButton = $("host-update-check-btn");
28194
+ if (updateCheckButton) {
28195
+ updateCheckButton.disabled =
28196
+ disabled ||
28197
+ hostPreferencesState.updateChecking ||
28198
+ hostPreferencesState.updateRunning;
28199
+ updateCheckButton.textContent = hostPreferencesState.updateChecking
28200
+ ? "Checking..."
28201
+ : "Check for updates";
28202
+ }
28203
+ const updateRunButton = $("host-update-run-btn");
28204
+ if (updateRunButton) {
28205
+ updateRunButton.disabled =
28206
+ disabled ||
28207
+ hostPreferencesState.updateChecking ||
28208
+ hostPreferencesState.updateRunning ||
28209
+ update.update_supported !== true ||
28210
+ update.update_available !== true;
28211
+ updateRunButton.textContent = hostPreferencesState.updateRunning
28212
+ ? "Updating..."
28213
+ : "Update now";
28214
+ }
28102
28215
  const qrPanel = $("host-login-qr-panel");
28103
28216
  const qr = hostPreferencesState.loginQr;
28104
28217
  if (qrPanel) {
@@ -28314,6 +28427,98 @@ Waiting for Claude login output…</pre
28314
28427
  }
28315
28428
  }
28316
28429
 
28430
+ async function checkOysterunUpdate() {
28431
+ if (
28432
+ hostPreferencesState.updateChecking ||
28433
+ hostPreferencesState.updateRunning
28434
+ ) {
28435
+ return;
28436
+ }
28437
+ hostPreferencesState = {
28438
+ ...hostPreferencesState,
28439
+ updateChecking: true,
28440
+ updateStatus: "Checking npm for updates...",
28441
+ updateError: "",
28442
+ };
28443
+ renderHostPreferences();
28444
+ try {
28445
+ const data = await apiWithAuthRetry("POST", "/admin/update-check", {
28446
+ channel: hostPreferencesState.update?.channel || null,
28447
+ });
28448
+ hostPreferencesState = {
28449
+ ...hostPreferencesState,
28450
+ update: data,
28451
+ updateChecking: false,
28452
+ updateStatus:
28453
+ data.update_available === true
28454
+ ? `Update available: ${data.latest_version}.`
28455
+ : "Oysterun is up to date for this channel.",
28456
+ updateError: "",
28457
+ };
28458
+ renderHostPreferences();
28459
+ } catch (err) {
28460
+ hostPreferencesState = {
28461
+ ...hostPreferencesState,
28462
+ updateChecking: false,
28463
+ updateStatus: "",
28464
+ updateError: err.message || "Could not check for updates.",
28465
+ };
28466
+ renderHostPreferences();
28467
+ }
28468
+ }
28469
+
28470
+ async function runOysterunUpdate() {
28471
+ if (
28472
+ hostPreferencesState.updateRunning ||
28473
+ hostPreferencesState.updateChecking
28474
+ ) {
28475
+ return;
28476
+ }
28477
+ const update = hostPreferencesState.update || {};
28478
+ if (
28479
+ update.update_supported !== true ||
28480
+ update.update_available !== true
28481
+ ) {
28482
+ return;
28483
+ }
28484
+ const confirmed = window.confirm(
28485
+ "Updating Oysterun restarts the Host. Existing live runtime sessions and running loops will close; chat history remains and supported sessions can be resumed from Sessions after restart. Continue?"
28486
+ );
28487
+ if (!confirmed) return;
28488
+ hostPreferencesState = {
28489
+ ...hostPreferencesState,
28490
+ updateRunning: true,
28491
+ updateStatus: "Scheduling Oysterun update...",
28492
+ updateError: "",
28493
+ };
28494
+ renderHostPreferences();
28495
+ try {
28496
+ const data = await apiWithAuthRetry("POST", "/admin/update-run", {
28497
+ channel: update.channel || null,
28498
+ });
28499
+ hostPreferencesState = {
28500
+ ...hostPreferencesState,
28501
+ update: data,
28502
+ updateRunning: false,
28503
+ updateStatus:
28504
+ "Oysterun update scheduled. Reconnect after the service comes back.",
28505
+ updateError: "",
28506
+ };
28507
+ renderHostPreferences();
28508
+ window.setTimeout(() => {
28509
+ window.location.href = "/app/settings";
28510
+ }, 6000);
28511
+ } catch (err) {
28512
+ hostPreferencesState = {
28513
+ ...hostPreferencesState,
28514
+ updateRunning: false,
28515
+ updateStatus: "",
28516
+ updateError: err.message || "Could not schedule update.",
28517
+ };
28518
+ renderHostPreferences();
28519
+ }
28520
+ }
28521
+
28317
28522
  function clearHostPreferencesInput() {
28318
28523
  discardUnsavedHostPreferencesChanges();
28319
28524
  }
@@ -41361,6 +41566,8 @@ Waiting for Claude login output…</pre
41361
41566
  window.saveHostPreferences = saveHostPreferences;
41362
41567
  window.generateDirectHostLoginQr = generateDirectHostLoginQr;
41363
41568
  window.restartHostFromPreferences = restartHostFromPreferences;
41569
+ window.checkOysterunUpdate = checkOysterunUpdate;
41570
+ window.runOysterunUpdate = runOysterunUpdate;
41364
41571
  window.refreshWebNotificationPreferenceState =
41365
41572
  refreshWebNotificationPreferenceState;
41366
41573
  window.sendWebNotificationTest = sendWebNotificationTest;
@@ -48,7 +48,9 @@ separate.
48
48
  [2/7] Host port
49
49
  2. Ask for Host port.
50
50
  - default production port is 8802.
51
- - if occupied, offer the next available port.
51
+ - if occupied by the same managed Oysterun service, allow the selected port
52
+ and restart that service at the final step.
53
+ - if occupied by another process, offer the next available port.
52
54
 
53
55
  [3/7] iPhone connection URL
54
56
  3. Ask how the iPhone will connect to this Mac.
@@ -82,9 +84,15 @@ separate.
82
84
 
83
85
  [7/7] Start Host
84
86
  12. Install the launchd service.
85
- 13. Ask whether to start Host now, default yes.
86
- 14. yes:
87
- launchd start
87
+ 13. If no managed Oysterun service is running, ask whether to start Host now,
88
+ default yes.
89
+ 14. If a managed Oysterun service is already running, ask whether to restart
90
+ Host now, default yes.
91
+ - warn that existing live runtime sessions and running loops will close.
92
+ - explain that chat history remains and supported sessions can be resumed
93
+ from Sessions after restart.
94
+ 15. yes:
95
+ launchd start or restart
88
96
  health check
89
97
  display Web URL
90
98
  on interactive macOS setup, open `<Host URL>/app/sessions` in the browser
@@ -113,6 +121,10 @@ If the Host is already running and setup changes runtime connection settings,
113
121
  setup should tell the user restart is required and offer to restart through the
114
122
  service manager.
115
123
 
124
+ Setup rerun must still go through the normal setup questions. A running Host
125
+ does not skip port selection, password update, QR creation, or the final
126
+ service action.
127
+
116
128
  ## Service Contract
117
129
 
118
130
  Release Host should run as a local managed service, not as a foreground
@@ -240,6 +252,42 @@ settings. For settings that require process restart:
240
252
  Runtime edits must not require rebuilding the phone app. Capacitor sync/build
241
253
  is only for app code changes, not day-to-day Host/IP changes.
242
254
 
255
+ ## Host Update Control
256
+
257
+ Host Preferences may expose package update controls for npm installs:
258
+
259
+ ```text
260
+ current Oysterun version
261
+ update channel, such as latest or beta
262
+ latest version from npm registry after manual check
263
+ Check for updates
264
+ Update now
265
+ ```
266
+
267
+ Update now is explicit. It must not run automatically on startup.
268
+
269
+ First implementation:
270
+
271
+ ```text
272
+ 1. Owner clicks Check for updates.
273
+ 2. Host queries npm registry for the selected channel.
274
+ 3. If a newer version exists, Host enables Update now.
275
+ 4. Owner clicks Update now and confirms the restart warning.
276
+ 5. Host runs npm install -g oysterun@<channel>.
277
+ 6. Host restarts through the same service restart path.
278
+ ```
279
+
280
+ Update now is supported only on the production npm Host stack. Dev/test stacks
281
+ may show version and check status, but must not mutate the global npm package.
282
+
283
+ Until runtime restore is implemented, update/restart UI must warn:
284
+
285
+ ```text
286
+ Updating restarts Oysterun Host. Existing live runtime sessions and running
287
+ loops will close; chat history remains and supported sessions can be resumed
288
+ from Sessions after restart.
289
+ ```
290
+
243
291
  ## Stage 2 Direction
244
292
 
245
293
  Stage 2 may add:
@@ -153,6 +153,38 @@ oysterun service:logs
153
153
  Explicit service commands should fail fast when setup has not completed. They
154
154
  must not silently create config or run setup.
155
155
 
156
+ ## Update Control
157
+
158
+ The npm package must support owner-visible update awareness after install.
159
+
160
+ Host Preferences may show:
161
+
162
+ ```text
163
+ current_version
164
+ channel: latest or beta
165
+ latest_version after manual npm registry check
166
+ Check for updates
167
+ Update now
168
+ ```
169
+
170
+ Update now must be explicit and restart-aware:
171
+
172
+ ```text
173
+ 1. Owner clicks Update now.
174
+ 2. Host confirms that updating restarts Oysterun Host.
175
+ 3. Host runs npm install -g oysterun@<channel>.
176
+ 4. Host restarts through the release service path.
177
+ 5. Host keeps config.json, cloud_identity.json, Matrix history, and agent
178
+ folders untouched.
179
+ ```
180
+
181
+ Update now is production-stack only. Dev/test stacks may check npm but must not
182
+ mutate the global npm installation from Host Preferences.
183
+
184
+ Until runtime restore is implemented, the update confirmation must warn that
185
+ live runtime sessions and running loops will close, while durable chat history
186
+ remains and supported sessions can be resumed from Sessions.
187
+
156
188
  ## Cloud Endpoint
157
189
 
158
190
  The production Cloud endpoint is a git-tracked product constant.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun-host-service",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.4",
4
4
  "type": "module",
5
5
  "description": "Oysterun host service — runs on Mac mini, manages Claude Code sessions",
6
6
  "scripts": {
@@ -4842,6 +4842,144 @@ function getActiveStackName() {
4842
4842
  );
4843
4843
  }
4844
4844
 
4845
+ const OYSTERUN_NPM_PACKAGE_NAME = "oysterun";
4846
+
4847
+ function readOysterunPackageVersion() {
4848
+ for (const packagePath of [
4849
+ join(REPO_ROOT, "package.json"),
4850
+ join(REPO_ROOT, "host-service", "package.json"),
4851
+ ]) {
4852
+ try {
4853
+ const parsed = JSON.parse(readFileSync(packagePath, "utf-8"));
4854
+ if (typeof parsed.version === "string" && parsed.version.trim()) {
4855
+ return parsed.version.trim();
4856
+ }
4857
+ } catch {
4858
+ // Best-effort diagnostics must not break Host Preferences.
4859
+ }
4860
+ }
4861
+ return "unknown";
4862
+ }
4863
+
4864
+ function inferOysterunUpdateChannel(version = readOysterunPackageVersion()) {
4865
+ return String(version || "").includes("-beta") ? "beta" : "latest";
4866
+ }
4867
+
4868
+ function normalizeOysterunUpdateChannel(value, fallback = null) {
4869
+ const normalized = normalizeString(value || fallback);
4870
+ if (normalized === "beta" || normalized === "latest") return normalized;
4871
+ return inferOysterunUpdateChannel();
4872
+ }
4873
+
4874
+ function compareVersionStrings(a, b) {
4875
+ const normalize = (value) =>
4876
+ String(value || "")
4877
+ .replace(/^v/, "")
4878
+ .split(/[-+.]/)
4879
+ .map((part) => {
4880
+ const number = Number.parseInt(part, 10);
4881
+ return Number.isNaN(number) ? part : number;
4882
+ });
4883
+ const left = normalize(a);
4884
+ const right = normalize(b);
4885
+ const length = Math.max(left.length, right.length);
4886
+ for (let index = 0; index < length; index += 1) {
4887
+ const l = left[index] ?? 0;
4888
+ const r = right[index] ?? 0;
4889
+ if (typeof l === "number" && typeof r === "number" && l !== r) {
4890
+ return l > r ? 1 : -1;
4891
+ }
4892
+ const ls = String(l);
4893
+ const rs = String(r);
4894
+ if (ls !== rs) return ls > rs ? 1 : -1;
4895
+ }
4896
+ return 0;
4897
+ }
4898
+
4899
+ function buildOysterunUpdateStatusPayload(extra = {}) {
4900
+ const currentVersion = readOysterunPackageVersion();
4901
+ const channel = normalizeOysterunUpdateChannel(extra.channel, currentVersion);
4902
+ const latestVersion = normalizeString(extra.latest_version);
4903
+ return {
4904
+ status: extra.status || "update_status",
4905
+ package_name: OYSTERUN_NPM_PACKAGE_NAME,
4906
+ current_version: currentVersion,
4907
+ channel,
4908
+ latest_version: latestVersion || null,
4909
+ update_available: latestVersion
4910
+ ? compareVersionStrings(latestVersion, currentVersion) > 0
4911
+ : false,
4912
+ update_supported: getActiveStackName() === "production",
4913
+ active_stack: getActiveStackName(),
4914
+ checked_at: extra.checked_at || null,
4915
+ error: extra.error || null,
4916
+ log_path: extra.log_path || null,
4917
+ };
4918
+ }
4919
+
4920
+ async function checkOysterunNpmUpdate({ channel = null } = {}) {
4921
+ const resolvedChannel = normalizeOysterunUpdateChannel(channel);
4922
+ const resp = await fetch(
4923
+ `https://registry.npmjs.org/${encodeURIComponent(OYSTERUN_NPM_PACKAGE_NAME)}`,
4924
+ { headers: { Accept: "application/json" } }
4925
+ );
4926
+ if (!resp.ok) {
4927
+ throw new Error(`npm registry returned HTTP ${resp.status}`);
4928
+ }
4929
+ const metadata = await resp.json();
4930
+ const latestVersion = normalizeString(metadata?.["dist-tags"]?.[resolvedChannel]);
4931
+ if (!latestVersion) {
4932
+ throw new Error(`npm dist-tag not found: ${resolvedChannel}`);
4933
+ }
4934
+ return buildOysterunUpdateStatusPayload({
4935
+ status: "update_check",
4936
+ channel: resolvedChannel,
4937
+ latest_version: latestVersion,
4938
+ checked_at: new Date().toISOString(),
4939
+ });
4940
+ }
4941
+
4942
+ function shellQuote(value) {
4943
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
4944
+ }
4945
+
4946
+ function scheduleOysterunPackageUpdate({ channel = null } = {}) {
4947
+ const stackName = getActiveStackName();
4948
+ if (stackName !== "production") {
4949
+ throw new Error("Oysterun package update is only available on the production npm Host stack.");
4950
+ }
4951
+ const resolvedChannel = normalizeOysterunUpdateChannel(channel);
4952
+ const logDir = join(getConfigDir(), "operation_logs");
4953
+ mkdirSync(logDir, { recursive: true });
4954
+ const logPath = join(
4955
+ logDir,
4956
+ `oysterun-update-${new Date().toISOString().replace(/[:.]/g, "-")}.log`
4957
+ );
4958
+ const packageSpec = `${OYSTERUN_NPM_PACKAGE_NAME}@${resolvedChannel}`;
4959
+ const command = [
4960
+ "set -euo pipefail",
4961
+ 'export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"',
4962
+ `echo "[oysterun-update] start $(date -Is)"`,
4963
+ `npm install -g ${shellQuote(packageSpec)}`,
4964
+ `echo "[oysterun-update] npm install complete $(date -Is)"`,
4965
+ "oysterun service:restart",
4966
+ ].join("\n");
4967
+ setTimeout(() => {
4968
+ const child = spawn("bash", ["-lc", `${command} >> ${shellQuote(logPath)} 2>&1`], {
4969
+ cwd: REPO_ROOT,
4970
+ detached: true,
4971
+ env: { ...process.env, OYSTERUN_RELEASE_STACK: "production" },
4972
+ stdio: "ignore",
4973
+ });
4974
+ child.unref();
4975
+ }, 500).unref();
4976
+ return buildOysterunUpdateStatusPayload({
4977
+ status: "update_scheduled",
4978
+ channel: resolvedChannel,
4979
+ log_path: logPath,
4980
+ });
4981
+ }
4982
+
4845
4983
  function scheduleHostRestart({ redirectUrl = null } = {}) {
4846
4984
  const stackName = getActiveStackName();
4847
4985
  if (!stackName) {
@@ -4918,6 +5056,7 @@ function buildHostPreferencesPayload() {
4918
5056
  config_path: getConfigPath(),
4919
5057
  config_dir: getConfigDir(),
4920
5058
  config_source: getConfigSource(),
5059
+ update: buildOysterunUpdateStatusPayload(),
4921
5060
  ios_push: buildApnsPushStatusPayload("dashboard-admin"),
4922
5061
  session_defaults: config.session_defaults,
4923
5062
  error: null,
@@ -8146,6 +8285,45 @@ const server = createServer(async (req, res) => {
8146
8285
  }
8147
8286
  }
8148
8287
 
8288
+ // ── Oysterun npm package update status/control (dashboard-authenticated) ──
8289
+ if (req.method === "GET" && path === "/admin/update-status") {
8290
+ if (!requireDashboardMode(claims, res)) return;
8291
+ return respond(res, 200, buildOysterunUpdateStatusPayload());
8292
+ }
8293
+
8294
+ if (req.method === "POST" && path === "/admin/update-check") {
8295
+ if (!requireDashboardMode(claims, res)) return;
8296
+ try {
8297
+ const body = await readJsonBody(req);
8298
+ return respond(
8299
+ res,
8300
+ 200,
8301
+ await checkOysterunNpmUpdate({ channel: body.channel })
8302
+ );
8303
+ } catch (err) {
8304
+ return respond(res, 409, {
8305
+ ...buildOysterunUpdateStatusPayload({
8306
+ status: "update_check_failed",
8307
+ error: err.message,
8308
+ }),
8309
+ });
8310
+ }
8311
+ }
8312
+
8313
+ if (req.method === "POST" && path === "/admin/update-run") {
8314
+ if (!requireDashboardMode(claims, res)) return;
8315
+ try {
8316
+ const body = await readJsonBody(req);
8317
+ return respond(
8318
+ res,
8319
+ 202,
8320
+ scheduleOysterunPackageUpdate({ channel: body.channel })
8321
+ );
8322
+ } catch (err) {
8323
+ return respond(res, 409, { error: err.message });
8324
+ }
8325
+ }
8326
+
8149
8327
  // ── Authenticated transcript cleanup dry-run/status APIs (P39E source only) ──
8150
8328
  if (
8151
8329
  req.method === "GET" &&
@@ -245,16 +245,39 @@ async function promptHidden(question) {
245
245
  });
246
246
  }
247
247
 
248
- function runReleaseService(command, args = [], opts = {}) {
248
+ function runReleaseService(command, args = [], opts = {}, options = {}) {
249
249
  const serviceStack = inferReleaseServiceStack(opts);
250
- execFileSync(releaseServiceScript, [command, ...args], {
250
+ const execOptions = {
251
251
  cwd: repoRoot,
252
- stdio: "inherit",
253
252
  env: {
254
253
  ...process.env,
255
254
  OYSTERUN_RELEASE_STACK: serviceStack,
256
255
  },
257
- });
256
+ };
257
+ if (!options.quiet) {
258
+ execFileSync(releaseServiceScript, [command, ...args], {
259
+ ...execOptions,
260
+ stdio: "inherit",
261
+ });
262
+ return "";
263
+ }
264
+ try {
265
+ const stdout = execFileSync(releaseServiceScript, [command, ...args], {
266
+ ...execOptions,
267
+ encoding: "utf8",
268
+ stdio: ["ignore", "pipe", "pipe"],
269
+ });
270
+ return stdout || "";
271
+ } catch (err) {
272
+ const stdout = err.stdout ? String(err.stdout).trim() : "";
273
+ const stderr = err.stderr ? String(err.stderr).trim() : "";
274
+ if (stdout || stderr) {
275
+ console.error(" Host service diagnostic output:");
276
+ if (stdout) console.error(stdout);
277
+ if (stderr) console.error(stderr);
278
+ }
279
+ throw err;
280
+ }
258
281
  }
259
282
 
260
283
  function inferReleaseServiceStack(opts = {}) {
@@ -275,6 +298,55 @@ function inferReleaseServiceStack(opts = {}) {
275
298
  return "production";
276
299
  }
277
300
 
301
+ function parseReleaseServiceStatusOutput(output) {
302
+ const status = {};
303
+ for (const line of String(output || "").split(/\r?\n/)) {
304
+ const match = line.match(/^([a-z_]+)=(.*)$/);
305
+ if (!match) continue;
306
+ status[match[1]] = match[2].trim();
307
+ }
308
+ return status;
309
+ }
310
+
311
+ function getReleaseServiceStatus(opts = {}) {
312
+ if (process.platform !== "darwin") {
313
+ return { available: false, running: false, status: {}, error: null };
314
+ }
315
+ try {
316
+ const output = runReleaseService("status", [], opts, { quiet: true });
317
+ const status = parseReleaseServiceStatusOutput(output);
318
+ const pid = status.pid && status.pid !== "-" ? status.pid : "";
319
+ const running =
320
+ status.loaded === "yes" || Boolean(pid) || status.health === "yes";
321
+ return { available: true, running, status, error: null };
322
+ } catch (err) {
323
+ return {
324
+ available: false,
325
+ running: false,
326
+ status: {},
327
+ error: err?.message || String(err),
328
+ };
329
+ }
330
+ }
331
+
332
+ function getUrlPort(value) {
333
+ try {
334
+ const parsed = new URL(String(value || ""));
335
+ if (parsed.port) return Number.parseInt(parsed.port, 10);
336
+ if (parsed.protocol === "https:") return 443;
337
+ if (parsed.protocol === "http:") return 80;
338
+ } catch {
339
+ return null;
340
+ }
341
+ return null;
342
+ }
343
+
344
+ function isPortOccupiedByManagedReleaseService(port, opts = {}) {
345
+ const releaseStatus = getReleaseServiceStatus(opts);
346
+ if (!releaseStatus.running) return false;
347
+ return getUrlPort(releaseStatus.status.url) === port;
348
+ }
349
+
278
350
  async function maybeOpenSetupBrowser(url) {
279
351
  if (!isInteractiveSetup() || process.platform !== "darwin" || !url) return;
280
352
  try {
@@ -295,26 +367,41 @@ async function maybeInstallOrStartReleaseService(opts, { hostUrl }) {
295
367
  throw new Error("release service install/start requires macOS launchd");
296
368
  }
297
369
 
298
- printSetupStep(7, "Start Host");
370
+ const serviceStatus = getReleaseServiceStatus(opts);
371
+ const serviceAlreadyRunning = serviceStatus.running === true;
372
+ printSetupStep(7, serviceAlreadyRunning ? "Restart Host" : "Start Host");
373
+ if (serviceAlreadyRunning && isInteractiveSetup()) {
374
+ console.log(" An Oysterun Host service is already running.");
375
+ console.log(
376
+ " Restarting will close existing live runtime sessions and running loops."
377
+ );
378
+ console.log(
379
+ " Chat history remains, and supported sessions can be resumed from Sessions after restart."
380
+ );
381
+ }
299
382
  const shouldStart = opts.noStartService
300
383
  ? false
301
384
  : opts.startService
302
385
  ? true
303
- : await promptYesNoDefaultYes("Start Oysterun Host now?");
386
+ : await promptYesNoDefaultYes(
387
+ serviceAlreadyRunning
388
+ ? "Restart Oysterun Host now?"
389
+ : "Start Oysterun Host now?"
390
+ );
304
391
 
305
392
  console.log();
306
393
  if (shouldStart) {
307
394
  console.log(
308
- ` Installing LaunchAgent and starting Oysterun Host (${inferReleaseServiceStack(opts)})...`
395
+ ` Installing LaunchAgent and ${serviceAlreadyRunning ? "restarting" : "starting"} Oysterun Host (${inferReleaseServiceStack(opts)})...`
309
396
  );
310
- runReleaseService("install", [], opts);
397
+ runReleaseService("install", [], opts, { quiet: true });
311
398
  console.log(`\n ✓ Oysterun Host is running at ${hostUrl}`);
312
399
  await maybeOpenSetupBrowser(`${hostUrl}/app/sessions`);
313
400
  } else {
314
401
  console.log(
315
402
  ` Installing LaunchAgent without starting Oysterun Host (${inferReleaseServiceStack(opts)})...`
316
403
  );
317
- runReleaseService("install", ["--no-start"], opts);
404
+ runReleaseService("install", ["--no-start"], opts, { quiet: true });
318
405
  console.log("\n Host service was installed but not started.");
319
406
  console.log(" Start later with:");
320
407
  console.log(" oysterun");
@@ -390,10 +477,16 @@ async function findAvailablePort(startPort) {
390
477
  return null;
391
478
  }
392
479
 
393
- async function promptHostPort({ defaultPort, optsPort }) {
480
+ async function promptHostPort({ defaultPort, optsPort, opts = {} }) {
394
481
  if (optsPort !== undefined) {
395
482
  const port = parsePortInput(optsPort);
396
483
  if (port === null) throw new Error(`Invalid port: ${optsPort}`);
484
+ if (
485
+ !(await isPortAvailable(port)) &&
486
+ !isPortOccupiedByManagedReleaseService(port, opts)
487
+ ) {
488
+ throw new Error(`Port ${port} is already in use`);
489
+ }
397
490
  return port;
398
491
  }
399
492
 
@@ -407,6 +500,12 @@ async function promptHostPort({ defaultPort, optsPort }) {
407
500
  continue;
408
501
  }
409
502
  if (await isPortAvailable(port)) return port;
503
+ if (isPortOccupiedByManagedReleaseService(port, opts)) {
504
+ console.log(
505
+ ` Port ${port} is used by the current Oysterun Host service and will be reused after restart.`
506
+ );
507
+ return port;
508
+ }
410
509
 
411
510
  const fallback = await findAvailablePort(port + 1);
412
511
  if (fallback && isInteractiveSetup()) {
@@ -646,6 +745,7 @@ async function configureDirectMode(opts, config) {
646
745
  const port = await promptHostPort({
647
746
  defaultPort: parsePortInput(currentPort) || defaultConfig.port,
648
747
  optsPort: opts.port,
748
+ opts,
649
749
  });
650
750
 
651
751
  const currentPublicBaseUrl = config.public_base_url || "";
@@ -727,6 +827,7 @@ async function configureCloudDirectMode(opts, config, backendUrl, backendStage)
727
827
  const port = await promptHostPort({
728
828
  defaultPort: parsePortInput(portValue) || defaultConfig.port,
729
829
  optsPort: opts.port,
830
+ opts,
730
831
  });
731
832
 
732
833
  const directHostUrl = await promptDirectHostUrl({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oysterun",
3
- "version": "0.1.1-beta.2",
3
+ "version": "0.1.1-beta.4",
4
4
  "type": "module",
5
5
  "description": "Oysterun Host installer and local service",
6
6
  "bin": {