@stagewhisper/stagewhisper 0.61.0 → 0.64.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StageWhisper
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
@@ -68,6 +68,13 @@ The plugin reads from `channels.stagewhisper` or `plugins.entries.stagewhisper.c
68
68
  | `relayToken` | Yes | Relay token from pairing |
69
69
  | `label` | No | Display label (default: "StageWhisper") |
70
70
 
71
+ When the plugin accepts tasks over HTTP (loopback transport), two opt-in environment variables widen the default localhost-only posture for remote setups such as `tailscale serve`:
72
+
73
+ | Variable | Default | Purpose |
74
+ |----------|---------|---------|
75
+ | `STAGEWHISPER_ALLOW_INGRESS_HOSTS` | unset | Comma-separated `Host` names accepted in addition to localhost. Set to your tailnet name when reaching the plugin through `tailscale serve`. |
76
+ | `STAGEWHISPER_ALLOW_CALLBACK_URLS` | unset | Comma-separated exact origins (scheme + host + port) the plugin may POST replies to. Loopback is trusted implicitly only while `STAGEWHISPER_ALLOW_INGRESS_HOSTS` is unset; once remote ingress is enabled, every callback origin (loopback included) must be listed here. |
77
+
71
78
  ## Architecture
72
79
 
73
80
  ```
package/dist/index.js CHANGED
@@ -3990,7 +3990,7 @@ var stagewhisperPlugin = {
3990
3990
  // src/http-transport.ts
3991
3991
  import http from "node:http";
3992
3992
  import { Buffer as Buffer2 } from "node:buffer";
3993
- import { timingSafeEqual } from "node:crypto";
3993
+ import { randomUUID, timingSafeEqual } from "node:crypto";
3994
3994
 
3995
3995
  // src/core.ts
3996
3996
  var TASK_ID_REGEX = /^[0-9a-f-]{36}$/;
@@ -4001,7 +4001,7 @@ var ALLOWED_REASONS = /* @__PURE__ */ new Set([
4001
4001
  "system_prelude"
4002
4002
  ]);
4003
4003
  var MAX_BODY_BYTES = 262144;
4004
- var MAX_PAYLOAD_TEXT_CHARS = 8e3;
4004
+ var MAX_PAYLOAD_TEXT_CHARS = 16e3;
4005
4005
  var MAX_SESSION_ID_CHARS = 128;
4006
4006
  var ALLOWED_HOSTNAMES = /* @__PURE__ */ new Set(["127.0.0.1", "localhost"]);
4007
4007
  function isTestTask(task) {
@@ -4055,6 +4055,42 @@ async function dispatchTaskToAgent(options) {
4055
4055
  function isLoopbackCallbackUrl(url) {
4056
4056
  return LOOPBACK_CALLBACK_URL_REGEX.test(url);
4057
4057
  }
4058
+ function normalizeOrigin(url) {
4059
+ let parsed;
4060
+ try {
4061
+ parsed = new URL(url);
4062
+ } catch {
4063
+ return null;
4064
+ }
4065
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
4066
+ return parsed.origin.toLowerCase();
4067
+ }
4068
+ function allowedCallbackOrigins() {
4069
+ const raw = process.env["STAGEWHISPER_ALLOW_CALLBACK_URLS"];
4070
+ if (!raw) return /* @__PURE__ */ new Set();
4071
+ const origins = raw.split(",").map((entry) => normalizeOrigin(entry.trim())).filter((origin) => origin !== null);
4072
+ return new Set(origins);
4073
+ }
4074
+ function isAllowedCallbackUrl(url) {
4075
+ let parsed;
4076
+ try {
4077
+ parsed = new URL(url);
4078
+ } catch {
4079
+ return false;
4080
+ }
4081
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
4082
+ if (parsed.pathname !== "/" && parsed.pathname !== "") return false;
4083
+ if (parsed.search || parsed.hash) return false;
4084
+ if (allowedCallbackOrigins().has(parsed.origin.toLowerCase())) return true;
4085
+ if (allowedIngressHosts().size === 0 && isLoopbackCallbackUrl(url)) return true;
4086
+ return false;
4087
+ }
4088
+ function allowedIngressHosts() {
4089
+ const raw = process.env["STAGEWHISPER_ALLOW_INGRESS_HOSTS"];
4090
+ if (!raw) return /* @__PURE__ */ new Set();
4091
+ const hosts = raw.split(",").map((entry) => entry.trim().toLowerCase()).filter((entry) => entry.length > 0);
4092
+ return new Set(hosts);
4093
+ }
4058
4094
  function isAllowedHostHeader(host) {
4059
4095
  if (!host) return false;
4060
4096
  const trimmed = host.trim().toLowerCase();
@@ -4062,9 +4098,9 @@ function isAllowedHostHeader(host) {
4062
4098
  const colonIdx = trimmed.lastIndexOf(":");
4063
4099
  const hostname = colonIdx === -1 ? trimmed : trimmed.slice(0, colonIdx);
4064
4100
  const port = colonIdx === -1 ? "" : trimmed.slice(colonIdx + 1);
4065
- if (!ALLOWED_HOSTNAMES.has(hostname)) return false;
4066
4101
  if (port && !/^\d+$/.test(port)) return false;
4067
- return true;
4102
+ if (ALLOWED_HOSTNAMES.has(hostname)) return true;
4103
+ return allowedIngressHosts().has(hostname);
4068
4104
  }
4069
4105
  function buildChatId(sessionId, reason) {
4070
4106
  if (reason === "transcript_chunk") return `sw:${sessionId}:reasoning`;
@@ -4139,10 +4175,10 @@ function validateHttpTaskRequest(body) {
4139
4175
  if (typeof cbUrl !== "string" || cbUrl.length === 0) {
4140
4176
  return { ok: false, error: "callback.url must be a non-empty string" };
4141
4177
  }
4142
- if (!isLoopbackCallbackUrl(cbUrl)) {
4178
+ if (!isAllowedCallbackUrl(cbUrl)) {
4143
4179
  return {
4144
4180
  ok: false,
4145
- error: "callback.url must be a loopback base URL (http://127.0.0.1:PORT or http://localhost:PORT) with no path"
4181
+ error: "callback.url must be a loopback base URL (http://127.0.0.1:PORT) or an origin listed in STAGEWHISPER_ALLOW_CALLBACK_URLS, with no path"
4146
4182
  };
4147
4183
  }
4148
4184
  if (typeof cbToken !== "string" || cbToken.length < 16) {
@@ -4212,7 +4248,14 @@ var IDEMPOTENCY_TTL_MS = 5 * 60 * 1e3;
4212
4248
  var CALLBACK_TIMEOUT_MS = 5e3;
4213
4249
  var CALLBACK_MAX_ATTEMPTS = 4;
4214
4250
  var CALLBACK_RETRY_BASE_MS = 250;
4215
- var SUBAGENT_WAIT_TIMEOUT_MS = 12e4;
4251
+ var POLL_INTERVAL_MS = 500;
4252
+ function resolveRuntimeEvents(api) {
4253
+ const runtime = api.runtime;
4254
+ const events = runtime?.events;
4255
+ if (!events) return null;
4256
+ if (typeof events.onSessionTranscriptUpdate !== "function") return null;
4257
+ return events;
4258
+ }
4216
4259
  var INCOMING_PATH = "/v1/incoming";
4217
4260
  var PING_PATH = "/v1/ping";
4218
4261
  function constantTimeTokenEqual(provided, expected) {
@@ -4227,7 +4270,7 @@ function constantTimeTokenEqual(provided, expected) {
4227
4270
  }
4228
4271
  function extractBearerToken(header) {
4229
4272
  if (!header) return null;
4230
- const match = /^Bearer\s+(.+)$/i.exec(header.trim());
4273
+ const match = /^Bearer\s+(\S.*)$/i.exec(header.trim());
4231
4274
  if (!match) return null;
4232
4275
  return match[1]?.trim() ?? null;
4233
4276
  }
@@ -4320,34 +4363,34 @@ function createHttpTransport(options) {
4320
4363
  if (prelude !== void 0) pendingPreludes.delete(sessionId);
4321
4364
  return prelude;
4322
4365
  }
4323
- async function extractReplyForChatTask(sessionKey, taskId, userMessageId, maxAttempts = 3) {
4324
- const markers = [`StageWhisper task: ${taskId}`];
4325
- if (userMessageId) markers.push(`[StageWhisper chat: ${userMessageId}]`);
4326
- for (let attempt = 0; attempt < maxAttempts; attempt++) {
4327
- if (attempt > 0) {
4328
- await new Promise((r) => setTimeout(r, 1e3));
4366
+ async function collectAssistantRepliesAfterMarker(sessionKey, markers) {
4367
+ const session = await api.runtime.subagent.getSessionMessages({
4368
+ sessionKey,
4369
+ limit: 100
4370
+ });
4371
+ const messages = session.messages;
4372
+ let markerIndex = -1;
4373
+ for (let i = messages.length - 1; i >= 0; i--) {
4374
+ const msg = messages[i];
4375
+ if (msg["role"] !== "user") continue;
4376
+ const text = extractContentFromMessage(msg) ?? "";
4377
+ if (markers.some((m) => text.includes(m))) {
4378
+ markerIndex = i;
4379
+ break;
4329
4380
  }
4330
- const session = await api.runtime.subagent.getSessionMessages({
4331
- sessionKey,
4332
- limit: 50
4333
- });
4334
- const messages = session.messages;
4335
- for (let i = 0; i < messages.length; i++) {
4336
- const msg = messages[i];
4337
- if (msg["role"] !== "user") continue;
4338
- const text = extractContentFromMessage(msg) ?? "";
4339
- if (!markers.some((m) => text.includes(m))) continue;
4340
- for (let j = i + 1; j < messages.length; j++) {
4341
- const reply = messages[j];
4342
- const role = reply["role"];
4343
- if (role === "assistant" || role === "model") {
4344
- return extractContentFromMessage(reply);
4345
- }
4346
- if (role === "user") break;
4347
- }
4381
+ }
4382
+ if (markerIndex === -1) return [];
4383
+ const replies = [];
4384
+ for (let j = markerIndex + 1; j < messages.length; j++) {
4385
+ const msg = messages[j];
4386
+ const role = msg["role"];
4387
+ if (role === "user") break;
4388
+ if (role === "assistant" || role === "model") {
4389
+ const text = extractContentFromMessage(msg);
4390
+ if (text) replies.push(text);
4348
4391
  }
4349
4392
  }
4350
- return null;
4393
+ return replies;
4351
4394
  }
4352
4395
  async function postCallback(callback, taskId, body) {
4353
4396
  const url = buildCallbackUrl(callback.url, taskId);
@@ -4428,11 +4471,27 @@ function createHttpTransport(options) {
4428
4471
  });
4429
4472
  const chatIdReason = kind === "chat" ? "chat_message" : "transcript_chunk";
4430
4473
  const chatId = taskRequest.chat_id ?? buildChatId(taskRequest.session_id, chatIdReason);
4431
- const occurredAt = (/* @__PURE__ */ new Date()).toISOString();
4432
- const sendCallback = async (body) => {
4474
+ const markers = [`StageWhisper task: ${taskRequest.task_id}`];
4475
+ if (userMessageId) markers.push(`[StageWhisper chat: ${userMessageId}]`);
4476
+ let forwardedCount = 0;
4477
+ const postMessage = async (replyText) => {
4478
+ const messageId = randomUUID();
4479
+ const body = {
4480
+ task_id: taskRequest.task_id,
4481
+ session_id: taskRequest.session_id,
4482
+ chat_id: chatId,
4483
+ user_message_id: userMessageId ?? null,
4484
+ message_id: messageId,
4485
+ status: "message",
4486
+ reply_text: replyText,
4487
+ occurred_at: (/* @__PURE__ */ new Date()).toISOString()
4488
+ };
4433
4489
  recordTerminalCallback(taskRequest.task_id, callback, body);
4434
4490
  try {
4435
4491
  await postCallback(callback, taskRequest.task_id, body);
4492
+ api.logger.info(
4493
+ `[stagewhisper-http] ${kind} task ${taskRequest.task_id} forwarded message ${messageId}`
4494
+ );
4436
4495
  } catch (err) {
4437
4496
  const errMsg = err instanceof Error ? err.message : String(err);
4438
4497
  api.logger.error(
@@ -4440,6 +4499,79 @@ function createHttpTransport(options) {
4440
4499
  );
4441
4500
  }
4442
4501
  };
4502
+ const postTyping = async () => {
4503
+ try {
4504
+ await postCallback(callback, taskRequest.task_id, {
4505
+ task_id: taskRequest.task_id,
4506
+ session_id: taskRequest.session_id,
4507
+ chat_id: chatId,
4508
+ user_message_id: userMessageId ?? null,
4509
+ status: "typing",
4510
+ label: "thinking"
4511
+ });
4512
+ } catch (err) {
4513
+ const errMsg = err instanceof Error ? err.message : String(err);
4514
+ api.logger.warn(
4515
+ `[stagewhisper-http] typing callback failed for ${taskRequest.task_id}: ${errMsg}`
4516
+ );
4517
+ }
4518
+ };
4519
+ const events = resolveRuntimeEvents(api);
4520
+ let unsubscribeTranscript = null;
4521
+ let pollDone = false;
4522
+ let flushing = null;
4523
+ let flushQueued = false;
4524
+ let wakePoll = () => {
4525
+ };
4526
+ const sleepUntilPoll = () => new Promise((resolve) => {
4527
+ const timer = setTimeout(() => {
4528
+ wakePoll = () => {
4529
+ };
4530
+ resolve();
4531
+ }, POLL_INTERVAL_MS);
4532
+ wakePoll = () => {
4533
+ clearTimeout(timer);
4534
+ wakePoll = () => {
4535
+ };
4536
+ resolve();
4537
+ };
4538
+ });
4539
+ const runFlushOnce = async () => {
4540
+ try {
4541
+ const replies = await collectAssistantRepliesAfterMarker(
4542
+ sessionKey,
4543
+ markers
4544
+ );
4545
+ while (forwardedCount < replies.length) {
4546
+ const reply = replies[forwardedCount];
4547
+ forwardedCount += 1;
4548
+ if (reply.trim()) await postMessage(reply);
4549
+ }
4550
+ } catch (err) {
4551
+ api.logger.warn(
4552
+ `[stagewhisper-http] session flush failed for ${taskRequest.task_id}: ${err}`
4553
+ );
4554
+ }
4555
+ };
4556
+ const flushFromSession = async () => {
4557
+ if (flushing) {
4558
+ flushQueued = true;
4559
+ await flushing;
4560
+ return;
4561
+ }
4562
+ flushing = (async () => {
4563
+ await runFlushOnce();
4564
+ while (flushQueued) {
4565
+ flushQueued = false;
4566
+ await runFlushOnce();
4567
+ }
4568
+ })();
4569
+ try {
4570
+ await flushing;
4571
+ } finally {
4572
+ flushing = null;
4573
+ }
4574
+ };
4443
4575
  try {
4444
4576
  const dispatch = await dispatchTaskToAgent({
4445
4577
  api,
@@ -4451,87 +4583,84 @@ function createHttpTransport(options) {
4451
4583
  api.logger.info(
4452
4584
  `[stagewhisper-http] ${kind} task ${taskRequest.task_id} dispatched (runId: ${dispatch.runId})`
4453
4585
  );
4586
+ const runId = dispatch.runId;
4587
+ await postTyping();
4588
+ if (events?.onSessionTranscriptUpdate) {
4589
+ unsubscribeTranscript = events.onSessionTranscriptUpdate((update) => {
4590
+ if (update.sessionKey && update.sessionKey !== sessionKey) return;
4591
+ const message = update.message;
4592
+ const role = message?.["role"];
4593
+ if (role !== void 0 && role !== "assistant" && role !== "model") {
4594
+ return;
4595
+ }
4596
+ void flushFromSession();
4597
+ });
4598
+ }
4599
+ let pollLoop = Promise.resolve();
4600
+ if (!events?.onSessionTranscriptUpdate) {
4601
+ pollLoop = (async () => {
4602
+ while (!pollDone) {
4603
+ await sleepUntilPoll();
4604
+ if (pollDone) break;
4605
+ await flushFromSession();
4606
+ }
4607
+ })();
4608
+ }
4454
4609
  const waitResult = await api.runtime.subagent.waitForRun({
4455
- runId: dispatch.runId,
4456
- timeoutMs: SUBAGENT_WAIT_TIMEOUT_MS
4610
+ runId
4457
4611
  });
4458
- if (waitResult.status === "ok") {
4459
- const reply = await extractReplyForChatTask(
4460
- sessionKey,
4461
- taskRequest.task_id,
4462
- userMessageId
4612
+ pollDone = true;
4613
+ wakePoll();
4614
+ await pollLoop;
4615
+ await flushFromSession();
4616
+ if (waitResult.status === "error") {
4617
+ api.logger.error(
4618
+ `[stagewhisper-http] ${kind} task ${taskRequest.task_id} agent error: ${waitResult.error}`
4463
4619
  );
4464
- if (reply) {
4465
- await sendCallback({
4466
- task_id: taskRequest.task_id,
4467
- session_id: taskRequest.session_id,
4468
- chat_id: chatId,
4469
- user_message_id: userMessageId ?? null,
4470
- status: "completed",
4471
- reply_text: reply,
4472
- occurred_at: occurredAt
4473
- });
4474
- api.logger.info(
4475
- `[stagewhisper-http] ${kind} task ${taskRequest.task_id} callback delivered`
4476
- );
4477
- } else if (kind === "reasoning") {
4478
- await sendCallback({
4479
- task_id: taskRequest.task_id,
4480
- session_id: taskRequest.session_id,
4481
- chat_id: chatId,
4482
- user_message_id: userMessageId ?? null,
4483
- status: "silent",
4484
- occurred_at: occurredAt
4485
- });
4486
- api.logger.info(
4487
- `[stagewhisper-http] reasoning task ${taskRequest.task_id} produced no signal (silent)`
4488
- );
4489
- } else {
4490
- await sendCallback({
4620
+ try {
4621
+ await postCallback(callback, taskRequest.task_id, {
4491
4622
  task_id: taskRequest.task_id,
4492
4623
  session_id: taskRequest.session_id,
4493
4624
  chat_id: chatId,
4494
4625
  user_message_id: userMessageId ?? null,
4495
4626
  status: "errored",
4496
- error_code: "no_reply",
4497
- error_message: "Agent run produced no reply",
4498
- occurred_at: occurredAt
4627
+ error_code: "agent_error",
4628
+ error_message: waitResult.error ?? "agent run error"
4499
4629
  });
4500
- api.logger.warn(
4501
- `[stagewhisper-http] ${kind} task ${taskRequest.task_id} produced no reply`
4630
+ } catch (postErr) {
4631
+ api.logger.error(
4632
+ `[stagewhisper-http] errored callback failed for ${taskRequest.task_id}: ${postErr}`
4502
4633
  );
4503
4634
  }
4504
4635
  } else {
4505
- await sendCallback({
4636
+ api.logger.info(
4637
+ `[stagewhisper-http] ${kind} task ${taskRequest.task_id} turn settled (${waitResult.status})`
4638
+ );
4639
+ }
4640
+ } catch (err) {
4641
+ const errMsg = err instanceof Error ? err.message : String(err);
4642
+ api.logger.error(
4643
+ `[stagewhisper-http] ${kind} task ${taskRequest.task_id} threw: ${errMsg}`
4644
+ );
4645
+ try {
4646
+ await postCallback(callback, taskRequest.task_id, {
4506
4647
  task_id: taskRequest.task_id,
4507
4648
  session_id: taskRequest.session_id,
4508
4649
  chat_id: chatId,
4509
4650
  user_message_id: userMessageId ?? null,
4510
4651
  status: "errored",
4511
- error_code: "agent_error",
4512
- error_message: waitResult.error ?? `agent run ${waitResult.status}`,
4513
- occurred_at: occurredAt
4652
+ error_code: "execution_error",
4653
+ error_message: errMsg
4514
4654
  });
4655
+ } catch (postErr) {
4515
4656
  api.logger.error(
4516
- `[stagewhisper-http] ${kind} task ${taskRequest.task_id} agent failed: ${waitResult.error}`
4657
+ `[stagewhisper-http] errored callback failed for ${taskRequest.task_id}: ${postErr}`
4517
4658
  );
4518
4659
  }
4519
- } catch (err) {
4520
- const errMsg = err instanceof Error ? err.message : String(err);
4521
- api.logger.error(
4522
- `[stagewhisper-http] ${kind} task ${taskRequest.task_id} threw: ${errMsg}`
4523
- );
4524
- await sendCallback({
4525
- task_id: taskRequest.task_id,
4526
- session_id: taskRequest.session_id,
4527
- chat_id: chatId,
4528
- user_message_id: userMessageId ?? null,
4529
- status: "errored",
4530
- error_code: "execution_error",
4531
- error_message: errMsg,
4532
- occurred_at: occurredAt
4533
- });
4534
4660
  } finally {
4661
+ pollDone = true;
4662
+ wakePoll();
4663
+ unsubscribeTranscript?.();
4535
4664
  inflight.delete(taskRequest.task_id);
4536
4665
  }
4537
4666
  }
@@ -4848,7 +4977,7 @@ var getRuntime = runtimeStore.getRuntime;
4848
4977
 
4849
4978
  // src/service.ts
4850
4979
  init_client();
4851
- import { randomUUID } from "node:crypto";
4980
+ import { randomUUID as randomUUID2 } from "node:crypto";
4852
4981
 
4853
4982
  // src/health.ts
4854
4983
  var DEGRADED_THRESHOLD = 3;
@@ -5382,7 +5511,7 @@ function createRelayService(api) {
5382
5511
  sessionKey,
5383
5512
  message: messageContent,
5384
5513
  deliver: false,
5385
- idempotencyKey: randomUUID()
5514
+ idempotencyKey: randomUUID2()
5386
5515
  });
5387
5516
  api.logger.info(`Test task ${task.id} dispatched (runId: ${result.runId})`);
5388
5517
  const waitResult = await api.runtime.subagent.waitForRun({
@@ -2,7 +2,7 @@
2
2
  "id": "stagewhisper",
3
3
  "name": "StageWhisper",
4
4
  "description": "Turn live call moments into assistant tasks via StageWhisper",
5
- "version": "0.61.0",
5
+ "version": "0.64.0",
6
6
  "channels": [
7
7
  "stagewhisper"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stagewhisper/stagewhisper",
3
- "version": "0.61.0",
3
+ "version": "0.64.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw channel plugin that connects StageWhisper live calls to your AI assistant",
6
6
  "license": "MIT",