@nowcrew/daemon 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/prompt.js +15 -8
  2. package/package.json +1 -1
  3. package/dist/remote/claude-bridge.js +0 -558
  4. package/dist/remote/claude-channel.js +0 -164
  5. package/dist/remote/codex-client.js +0 -451
  6. package/dist/remote/codex-runtime.js +0 -77
  7. package/dist/remote/config.js +0 -135
  8. package/dist/remote/gateway.js +0 -879
  9. package/dist/remote/identity.js +0 -39
  10. package/dist/remote/owner.js +0 -77
  11. package/dist/remote/protocol.js +0 -211
  12. package/dist/remote/remote-cli.js +0 -254
  13. package/dist/remote/runtime-probe.js +0 -182
  14. package/dist/remote/session-discovery.js +0 -249
  15. package/dist/remote/wrapper.js +0 -40
  16. package/dist/remote-web/assets/index-B_6VM_tw.js +0 -94
  17. package/dist/remote-web/assets/index-L6EiQbJn.css +0 -1
  18. package/dist/remote-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  19. package/dist/remote-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  20. package/dist/remote-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  21. package/dist/remote-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  22. package/dist/remote-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  23. package/dist/remote-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  24. package/dist/remote-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  25. package/dist/remote-web/icons/nowwork-192.png +0 -0
  26. package/dist/remote-web/icons/nowwork-512.png +0 -0
  27. package/dist/remote-web/icons/nowwork.svg +0 -7
  28. package/dist/remote-web/index.html +0 -20
  29. package/dist/remote-web/manifest.webmanifest +0 -13
  30. package/dist/remote-web/sw.js +0 -12
package/dist/prompt.js CHANGED
@@ -82,7 +82,7 @@ export function buildSystemPrompt(ctx) {
82
82
  // 消息、没有 task,这条规则字面上无法执行,省略(2026-07-13 复审 Minor 5)。
83
83
  const claimRule = scheduled
84
84
  ? ""
85
- : "\n- **除定时任务创建与管理外,动手干活前必须先 `crew task claim`**;claim 失败就转做别的任务。";
85
+ : "\n- **除定时任务创建与管理和 memory_prune 外,动手干活前必须先 `crew task claim`**;claim 失败就转做别的任务。";
86
86
  // "一线程一 task 且必须带 --thread"与"进度/产出回本线程,严禁发到顶层"都建立在"本轮有一条
87
87
  // 触发消息、线程根即为该消息 id"这个前提上——scheduled run 没有触发消息、没有 thread 锚点,
88
88
  // 唯一输出路径就是频道顶层 `--send-draft`,与这两条正面矛盾且 --thread 参数根本填不出来,
@@ -114,14 +114,14 @@ export function buildSystemPrompt(ctx) {
114
114
  : "- **始终只通过 crew CLI 发声。在 crew 命令之外产生的任何文字都不会送达任何人。**";
115
115
  const actionRule = scheduled
116
116
  ? `- **判定规则**:直接执行本轮定时指令,执行前不需要 claim。${threadTaskRule}${progressRule}`
117
- : `- **判定规则**:定时任务创建与管理直接按上节执行,无需 task claim;其它来信若需要你"回复之外的动作"(跑工具/改代码/做变更),先 claim;若只是回答问题或闲聊,无需 claim。${threadTaskRule}${progressRule}`;
117
+ : `- **判定规则**:定时任务创建与管理和 memory_prune 无需 task claim,只遵守各自唤醒指令;其它来信若需要你"回复之外的动作"(跑工具/改代码/做变更),先 claim;若只是回答问题或闲聊,无需 claim。${threadTaskRule}${progressRule}`;
118
118
  const taskAndScheduleCommands = alwaysReport
119
119
  ? `4. **\`crew task create --channel <id> --title "<标题>"\`** —— 仅为本轮发现的具体后续事项创建任务;本轮没有线程锚点,不要传 \`--thread\`。`
120
120
  : scheduled
121
121
  ? `5. **\`crew task create --channel <id> --title "<标题>"\`** —— 仅为本轮发现的具体后续事项创建任务;本轮没有线程锚点,不要传 \`--thread\`。`
122
122
  : `5. **\`crew task list --channel <id>\`** —— 看任务板。支持 \`--status <s>\` / \`--mine\`。
123
123
  6. **\`crew task create --channel <id> --title "<标题>" --thread <当前线程根msgId>\`** —— 新建任务并**绑定到当前线程**。\`--thread\` 传你读到的那条**触发消息 id**(线程根),任务就和讨论同处一个线程。省略 \`--thread\` 会另起新线程,**几乎总是错的——务必带上**。
124
- 7. **\`crew task claim <taskId>\`** —— 除定时任务创建与管理外,执行需要动作的工作前先认领任务。
124
+ 7. **\`crew task claim <taskId>\`** —— 除定时任务创建与管理和 memory_prune 外,执行需要动作的工作前先认领任务。
125
125
  8. **\`crew task update <taskId> --status <in_progress|in_review|done>\`** —— 推进任务状态。
126
126
  9. **\`crew task unclaim <taskId>\`** —— 释放认领,把任务让给别人。
127
127
  10. **\`crew task assign <taskId> --to <handle>\`** —— 把任务指派/交接给另一个 agent(用于交接,见下)。
@@ -176,7 +176,7 @@ ${taskAndScheduleCommands}`;
176
176
  ## 协作礼仪
177
177
  - **尊重正在进行的对话**:人类正与他人一来一回时,除非明确 @你或显然在叫你,否则不要插话。
178
178
  - **只有真正干活的人来汇报**:别替别人总结或冒领他们的工作。
179
- - **除定时任务创建与管理外,claim 后再动手**:claim 失败立即停手,换一个任务。
179
+ - **除定时任务创建与管理和 memory_prune 外,claim 后再动手**:claim 失败立即停手,换一个任务。
180
180
  - **停止前检查你欠的具体阻塞项**:若你还欠某人一个 handoff/review/决定/回复且正卡着对方,先发一条最小可行动消息再停。
181
181
  - **少发废话**:只在有可行动内容时发消息,不要播报"我在等/我空闲"。`;
182
182
  // 对人说话:约束的是"表达方式",不是"执行规则"——claim/thread/freshness 等操作协议照常执行,
@@ -234,8 +234,15 @@ ${taskAndScheduleCommands}`;
234
234
 
235
235
  ## 并行与记忆(CRITICAL — 防冲突 + 防上下文膨胀)
236
236
  你**可能同时在处理多个任务**(你自己的多个并行进程)。为避免互相覆盖、并保持上下文精简,严格遵守:
237
- - **只写两处**:① 本任务的隔离 cwd(代码/草稿);② 本任务的工作日志 **\`$CREW_TASK_LOG\`**(每任务一份,别人不会碰)。在工作日志里记:当前阶段 / 下一步 / 卡在谁 / 关键结论(每条尽量 ≤3 行)。
238
- - **普通任务进行中不要改 \`$CREW_HOME/MEMORY.md\` notes/**(那是共享文件,多个并行运行同时写会冲突)。这些只读参考;任务 done/closed 后的专门收尾执行才按收尾指令写入功能明细和索引。
237
+ - **普通任务默认只写两处**:① 本任务的隔离 cwd(代码/草稿);② 本任务的工作日志 **\`$CREW_TASK_LOG\`**(每任务一份,别人不会碰)。默认不要修改共享 \`$CREW_HOME/MEMORY.md\` 或 notes/;只有当前用户明确要求更新或记住长期记忆,或明确表达具体且跨后续任务持续生效的规则、偏好或约束时,才按下方条件写入相关共享文件。在工作日志里记:当前阶段 / 下一步 / 卡在谁 / 关键结论(每条尽量 ≤3 行)。
238
+ - **当前请求明确要求更新或记住长期记忆,明确表达需要跨后续任务持续生效的具体规则、偏好或约束,或当前消息是对本线程紧邻记忆方案的无歧义确认时**,可以按最小范围直接写共享记忆。例如“以后所有测试报告统一按五部分写”属于明确长期意图,即使没有使用“记忆”一词。先读取相关索引和 note,语义已存在时不重复追加,不要把 Agent 自己判断值得记住视为用户授权。
239
+ - 本节的直接更新共享记忆和相关 task 流转仅适用于用户消息触发的普通交互执行。memory_prune 和 scheduled 执行只遵守各自唤醒指令,不得依据本节认领、创建、重开或推进 task。
240
+ - 普通交互执行将写共享记忆时,使用当前线程唯一的普通 task:已有非终态 task 时先按归属规则确保当前 Agent 成功认领;当前线程没有 task 时先创建并认领一个普通 task;已有 task 为 done/closed 时不得由 Agent 重开,done 报告当前轻量方案无法安全复用终态 task,closed 保持人工终态。遇到终态或归属问题时报告冲突且不得创建第二个 task 或新的任务类型。完成写入后,只有任务本身的全部工作已完成时才推进终态以触发 prune。
241
+ - 提出共享记忆修改方案并等待用户确认时,该 task 仍有剩余工作,保持 in_review 或其它非终态;收到无歧义确认后再执行写入,并仅在全部工作完成后推进终态。
242
+ - 普通执行直接更新后,仍然保留任务结束后的专门收尾执行,不因已经写过而跳过 prune。
243
+ - 新建 note 必须说明现有分类为何不适用;MEMORY.md 默认只更新 Knowledge Index,不得默认修改 Role、Core Goals 或 Active Context;只有用户明确要求或任务本身就是档案/状态维护时例外。
244
+ - 仅对本次或当前任务生效的要求不持久化。未明确要求、记忆意图含糊或仅由 Agent 自己判断值得记住时,不直接写共享记忆,也不把含糊内容写成长期记忆候选。例如“以后注意一下”等缺少具体内容或适用范围的表达仍属于含糊意图。含糊表达本身不作为长期记忆候选,但不得因此跳过整个 task 的 memory_prune;任务按正常状态流程完成。收尾只能处理该任务其它符合现有收尾规则的稳定内容,不允许让收尾代替用户补充记忆授权。
245
+ - 若用户明确要求本次不要修改共享记忆,同样不写共享记忆或长期记忆候选。任务尚未完成时,把该禁止要求作为运行状态写入 $CREW_TASK_LOG;该运行状态不是长期记忆候选,后续执行必须保留该状态。若用户之后在同一任务中明确撤销禁止,移除该运行状态并按最新明确授权处理。完成 task 前检查该状态:Agent 可自行改状态时,使用 crew task update <taskId> --status done --skip-memory-prune。CLI 返回状态由 Server 管理时沿用上述 Server 管理任务的安全回退:不重试、不绕过权限;收尾会读取该运行状态并禁止共享记忆写入,但本路径不提供机器级 skip 保证。不要只在回复里声明不修改。
239
246
  - **跨任务协调看实时清单**:用 \`crew task list --mine\` 查你当前所有任务及其状态(谁在排队、谁在评审)。例:你一次只能测一个页面时,据此判断"正在测 #1、#2 排队",在 #2 的工作日志里标注"等 #1 完成"。
240
247
  - 需要更多背景时再 \`Read $CREW_HOME/notes/<topic>.md\`;不要一次把所有笔记读进上下文。
241
248
 
@@ -261,7 +268,7 @@ ${interactiveTaskRules}${freshnessRule}${externalReplyRule}${collaborationEtique
261
268
  ## Workspace 与分层记忆(CRITICAL — 索引+按需,配合上面的并行规则)
262
269
  你的持久记忆在 \`$CREW_HOME\`(跨你所有任务共享),按“常驻索引 + 功能明细 + 当前工作”分层:
263
270
  1. **MEMORY.md = 常驻索引 + 角色**:系统每次已把它(截断索引版)注入本提示词末尾。索引条目包含 \`file/type/scope/summary/triggers/priority/updated\`,其中 file 是相对于 \`$CREW_HOME\` 的路径(例如 \`notes/testing.md\`),先用这些字段判断当前问题是否相关;通常不必再读整份 MEMORY.md。
264
- 2. **notes/<topic>.md = 功能明细**:按用户偏好、项目规则、测试、调试、Git、事故复盘等主题拆分。优先读取与当前任务匹配的文件;\`notes/lessons.md\` 只保留未分类经验和历史兼容内容,不是所有新经验的默认目的地。**普通任务进行中只读不写**(多个并行运行同时写会冲突);任务结束后的专门收尾执行按收尾指令写入。
271
+ 2. **notes/<topic>.md = 功能明细**:按用户偏好、项目规则、测试、调试、Git、事故复盘等主题拆分。优先读取与当前任务匹配的文件;\`notes/lessons.md\` 只保留未分类经验和历史兼容内容,不是所有新经验的默认目的地。普通任务默认只读不写,但当前用户明确要求时可以按最小范围更新;任务结束后的专门收尾执行仍按收尾指令复核。
265
272
  - 对旧版 MEMORY.md,如果条目没有完整结构化字段但明确指向 \`notes/<file>.md\`,把它视为 legacy 索引;仍需结合条目标题/链接和当前请求判断相关性,不要因为缺少 summary/triggers 就假定正文已注入。
266
273
  3. **本任务 work-log = \`$CREW_TASK_LOG\`**:每任务一份(别的运行不会碰),记当前阶段/下一步/卡点/关键结论。系统已把它的当前内容注入末尾让你续上;有进展就更新它——这是你**唯一**该在任务中持续写的记忆文件。**work-log 是现状快照,不是流水账**:保持 ≤100 行,更新时重写全文替换过时内容,不要无限追加(过长会被注入截断)。
267
274
 
@@ -269,7 +276,7 @@ ${interactiveTaskRules}${freshnessRule}${externalReplyRule}${collaborationEtique
269
276
  - 当前请求命中 MEMORY.md 某条目的 \`summary\` 或 \`triggers\`,或用户明确询问历史经验/偏好时,在最终回答或执行动作前用 \`Read $CREW_HOME/<file>\` 读取对应明细;不要再次拼接 \`notes/\`。
270
277
  - 未命中时不要为了“保险”读取全部 notes;只读取能直接影响当前任务的文件。MEMORY.md 注入的是索引,不会自动让 note 正文可用。
271
278
 
272
- **沉淀与瘦身由系统触发,不要平时做**:任务被改为 done/closed 时,系统会单独唤醒你收尾——那时按功能分类把可复用经验提炼进合适的 \`notes/<topic>.md\`,更新 MEMORY.md 索引。收尾只有三种清理结果:有内容且明细与索引全部写入成功后清空 work-log;无可留存内容时直接清空;任一记忆写入失败则保留 work-log、停止并报告失败,不得继续清空。不要默认全部写入 \`notes/lessons.md\`。平时别动 MEMORY.md/notes,既防并行冲突,也防 MEMORY.md 膨胀。
279
+ **沉淀与瘦身由系统触发,不要平时自行扩展范围**:任务被改为 done/closed 时,系统会单独唤醒你收尾——那时按功能分类把可复用经验提炼进合适的 \`notes/<topic>.md\`,更新 MEMORY.md 索引。普通执行已经按用户明确要求写入时,收尾先检查、后修改,只在明确缺漏时做最小必要修正,不得重复追加或只为优化措辞而改写。最终结论必须且只能归类为:无变化、已修正、冲突、失败;无变化或已修正时必须满足对应后置条件并清空 work-log,冲突或失败时保留 work-log,任一记忆写入失败则保留 work-log。不要默认全部写入 \`notes/lessons.md\`。默认不要改 MEMORY.md/notes,但不能覆盖当前用户明确授权的最小范围更新。
273
280
 
274
281
  **为什么(CRITICAL)**:上下文会被周期压缩;你的恢复力来自——注入的 MEMORY.md 索引(始终精简)+ 本任务 work-log + 按需读相关 notes,而**不是**把一切堆进 MEMORY.md 或一次性加载所有明细。
275
282
  ${ctx.memory ? `\n## [注入] 你的 MEMORY.md(索引,只读参考)\n${ctx.memory}` : ""}${ctx.workLog ? `\n## [注入] 本任务 work-log(续上进度;更新写到 $CREW_TASK_LOG)\n${ctx.workLog}` : ""}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowcrew/daemon",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "type": "module",
5
5
  "description": "crew daemon — 运行在用户机器:拉起/管理 agent 进程,注入 crew CLI,归一化 runtime 事件",
6
6
  "license": "Apache-2.0",
@@ -1,558 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { createInterface } from "node:readline";
3
- import { query, } from "@anthropic-ai/claude-agent-sdk";
4
- import { WebSocket } from "ws";
5
- import { z } from "zod";
6
- import { toConsoleLines } from "../console.js";
7
- import { ChannelToGatewaySchema, GatewayToChannelSchema, MAX_CLAUDE_INPUT_QUEUE_BYTES, MAX_CLAUDE_INPUT_QUEUE_ITEMS, MAX_REMOTE_PROMPT_BYTES, MAX_REMOTE_WEBSOCKET_BUFFER_BYTES, REMOTE_PROTOCOL_VERSION, } from "./protocol.js";
8
- import { resolveClaudeWrapperSession } from "./wrapper.js";
9
- const MAX_CLAUDE_INPUT_DEDUP_ENTRIES = 1_000;
10
- export function claudeInputQueueStatus(outstandingItems, outstandingBytes, text) {
11
- const byteLength = Buffer.byteLength(text, "utf8");
12
- if (byteLength > MAX_REMOTE_PROMPT_BYTES)
13
- return "prompt_too_large";
14
- if (outstandingItems >= MAX_CLAUDE_INPUT_QUEUE_ITEMS
15
- || outstandingBytes + byteLength > MAX_CLAUDE_INPUT_QUEUE_BYTES)
16
- return "queue_full";
17
- return "accepted";
18
- }
19
- export function shouldReconnectClaudeChannel(closeCode) {
20
- return closeCode !== 4001;
21
- }
22
- class InputStream {
23
- values = [];
24
- waiters = [];
25
- stopped = false;
26
- push(value) {
27
- if (this.stopped)
28
- return;
29
- const waiter = this.waiters.shift();
30
- if (waiter)
31
- waiter({ value, done: false });
32
- else
33
- this.values.push(value);
34
- }
35
- close() {
36
- this.stopped = true;
37
- for (const waiter of this.waiters.splice(0))
38
- waiter({ value: undefined, done: true });
39
- }
40
- [Symbol.asyncIterator]() {
41
- return {
42
- next: () => {
43
- const value = this.values.shift();
44
- if (value)
45
- return Promise.resolve({ value, done: false });
46
- if (this.stopped)
47
- return Promise.resolve({ value: undefined, done: true });
48
- return new Promise((resolve) => this.waiters.push(resolve));
49
- },
50
- };
51
- }
52
- }
53
- const AskQuestionInputSchema = z.object({
54
- questions: z.array(z.object({
55
- question: z.string().min(1),
56
- header: z.string().default("Question"),
57
- options: z.array(z.object({
58
- label: z.string(),
59
- description: z.string().default(""),
60
- }).passthrough()).default([]),
61
- }).passthrough()).min(1).max(4),
62
- }).passthrough();
63
- function takeValue(args, index, option) {
64
- const value = args[index + 1];
65
- if (!value || value.startsWith("-"))
66
- throw new Error(`${option} requires a value`);
67
- return value;
68
- }
69
- function splitTools(value) {
70
- return value.split(/[ ,]+/).map((part) => part.trim()).filter(Boolean);
71
- }
72
- export function parseClaudeBridgeArgs(userArgs) {
73
- const resolved = resolveClaudeWrapperSession(userArgs);
74
- const options = {};
75
- const prompts = [];
76
- let title;
77
- const args = resolved.args;
78
- for (let index = 0; index < args.length; index += 1) {
79
- const arg = args[index];
80
- const inline = (name) => arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null;
81
- if (["--session-id", "--resume", "-r"].includes(arg)) {
82
- index += 1;
83
- continue;
84
- }
85
- if (arg.startsWith("--session-id=") || arg.startsWith("--resume="))
86
- continue;
87
- if (arg === "--model" || arg === "--effort" || arg === "--permission-mode" || arg === "--name" || arg === "-n"
88
- || arg === "--add-dir" || arg === "--allowedTools" || arg === "--allowed-tools"
89
- || arg === "--disallowedTools" || arg === "--disallowed-tools" || arg === "--settings") {
90
- const value = takeValue(args, index, arg);
91
- index += 1;
92
- if (arg === "--model")
93
- options.model = value;
94
- else if (arg === "--effort")
95
- options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(value);
96
- else if (arg === "--permission-mode")
97
- options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(value);
98
- else if (arg === "--name" || arg === "-n")
99
- title = value;
100
- else if (arg === "--add-dir")
101
- (options.additionalDirectories ??= []).push(value);
102
- else if (arg === "--allowedTools" || arg === "--allowed-tools")
103
- options.allowedTools = splitTools(value);
104
- else if (arg === "--disallowedTools" || arg === "--disallowed-tools")
105
- options.disallowedTools = splitTools(value);
106
- else
107
- options.settings = value;
108
- continue;
109
- }
110
- const model = inline("--model");
111
- const effort = inline("--effort");
112
- const permission = inline("--permission-mode");
113
- const name = inline("--name");
114
- if (model !== null)
115
- options.model = model;
116
- else if (effort !== null)
117
- options.effort = z.enum(["low", "medium", "high", "xhigh", "max"]).parse(effort);
118
- else if (permission !== null)
119
- options.permissionMode = z.enum(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"]).parse(permission);
120
- else if (name !== null)
121
- title = name;
122
- else if (arg === "--dangerously-skip-permissions") {
123
- options.permissionMode = "bypassPermissions";
124
- options.allowDangerouslySkipPermissions = true;
125
- }
126
- else if (arg.startsWith("-")) {
127
- throw new Error(`Claude mobile bridge does not support ${arg}`);
128
- }
129
- else
130
- prompts.push(arg);
131
- }
132
- const hasResume = userArgs.some((value) => value === "--resume" || value === "-r" || value.startsWith("--resume="));
133
- return {
134
- sessionId: resolved.sessionId,
135
- resume: hasResume,
136
- ...(title === undefined ? {} : { title }),
137
- ...(prompts.length === 0 ? {} : { initialPrompt: prompts.join(" ") }),
138
- options,
139
- };
140
- }
141
- function userMessage(sessionId, text, uuid, timestamp) {
142
- return {
143
- type: "user",
144
- message: { role: "user", content: text },
145
- parent_tool_use_id: null,
146
- session_id: sessionId,
147
- uuid,
148
- timestamp,
149
- };
150
- }
151
- function preview(input) {
152
- const command = typeof input.command === "string" ? input.command : null;
153
- const path = typeof input.file_path === "string" ? input.file_path : null;
154
- return (command ?? path ?? JSON.stringify(input)).slice(0, 16_000);
155
- }
156
- function printMessage(message) {
157
- let printedText = false;
158
- for (const line of toConsoleLines(message)) {
159
- if (line.stream === "result" || !line.text.trim())
160
- continue;
161
- if (line.stream === "text")
162
- printedText = true;
163
- const prefix = line.stream === "thinking" ? "Thinking: "
164
- : line.stream === "tool" ? "Tool: "
165
- : line.stream === "tool_result" ? "Result: "
166
- : line.stream === "error" ? "Error: " : "";
167
- process.stdout.write(`\n${prefix}${line.text}\n`);
168
- }
169
- return printedText;
170
- }
171
- export async function runClaudeBridge(userArgs, context) {
172
- const parsed = parseClaudeBridgeArgs(userArgs);
173
- const generation = randomUUID();
174
- const inputStream = new InputStream();
175
- const inputs = [];
176
- const interactions = new Map();
177
- const acceptedRemoteInputs = new Set();
178
- const terminalInteractions = [];
179
- let terminalHandler = null;
180
- let active = null;
181
- let outstandingInputBytes = 0;
182
- let stopped = false;
183
- let cleaned = false;
184
- let superseded = false;
185
- let channelReady = false;
186
- let hasRegisteredOnce = false;
187
- let socket = null;
188
- let reconnectTimer;
189
- let assistantPrinted = false;
190
- let conversation = null;
191
- let terminal = null;
192
- const url = new URL("/ws/channel", context.gatewayUrl);
193
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
194
- const send = (value) => {
195
- if (socket?.readyState !== WebSocket.OPEN)
196
- return false;
197
- const frame = ChannelToGatewaySchema.parse(value);
198
- if (frame.type !== "channel.register" && !channelReady)
199
- return false;
200
- const serialized = JSON.stringify(frame);
201
- if (socket.bufferedAmount + Buffer.byteLength(serialized, "utf8") > MAX_REMOTE_WEBSOCKET_BUFFER_BYTES) {
202
- socket.close(1013, "outbound buffer limit");
203
- return false;
204
- }
205
- try {
206
- socket.send(serialized);
207
- return true;
208
- }
209
- catch {
210
- return false;
211
- }
212
- };
213
- const register = () => send({
214
- type: "channel.register",
215
- protocolVersion: REMOTE_PROTOCOL_VERSION,
216
- sessionId: parsed.sessionId,
217
- cwd: context.cwd,
218
- pid: process.pid,
219
- generation,
220
- ...(parsed.title === undefined ? {} : { title: parsed.title }),
221
- });
222
- const sendActiveLocalUserMessage = () => {
223
- if (!active || active.requestId !== null)
224
- return;
225
- send({
226
- type: "bridge.user_message",
227
- protocolVersion: REMOTE_PROTOCOL_VERSION,
228
- sessionId: parsed.sessionId,
229
- messageId: active.messageId,
230
- text: active.text,
231
- createdAt: active.createdAt,
232
- });
233
- };
234
- const finishInteraction = (requestId, value, local) => {
235
- const interaction = interactions.get(requestId);
236
- if (!interaction)
237
- return false;
238
- interactions.delete(requestId);
239
- const index = terminalInteractions.indexOf(requestId);
240
- if (index >= 0)
241
- terminalInteractions.splice(index, 1);
242
- terminalHandler = null;
243
- interaction.resolve(value);
244
- if (local)
245
- send({
246
- type: "bridge.resolved",
247
- protocolVersion: REMOTE_PROTOCOL_VERSION,
248
- sessionId: parsed.sessionId,
249
- requestId,
250
- });
251
- return true;
252
- };
253
- const stop = () => {
254
- if (cleaned)
255
- return;
256
- cleaned = true;
257
- stopped = true;
258
- channelReady = false;
259
- if (reconnectTimer)
260
- clearTimeout(reconnectTimer);
261
- inputStream.close();
262
- conversation?.close();
263
- terminal?.close();
264
- socket?.close();
265
- for (const id of [...interactions.keys()])
266
- finishInteraction(id, { decision: "deny" }, true);
267
- };
268
- const acknowledgeDelivery = (deliveryId, status) => {
269
- send({
270
- type: "channel.delivery_ack",
271
- protocolVersion: REMOTE_PROTOCOL_VERSION,
272
- sessionId: parsed.sessionId,
273
- generation,
274
- deliveryId,
275
- status,
276
- });
277
- };
278
- const connectGateway = () => new Promise((resolve, reject) => {
279
- const next = new WebSocket(url, { headers: { authorization: `Bearer ${context.token}` } });
280
- socket = next;
281
- channelReady = false;
282
- let settled = false;
283
- let registrationTimer;
284
- next.once("open", () => {
285
- if (!register()) {
286
- next.close(1013, "registration delivery failed");
287
- return;
288
- }
289
- registrationTimer = setTimeout(() => next.close(1008, "registration timeout"), 2_000);
290
- });
291
- next.on("message", (raw) => {
292
- let decoded;
293
- try {
294
- decoded = JSON.parse(raw.toString());
295
- }
296
- catch {
297
- return;
298
- }
299
- const frame = GatewayToChannelSchema.safeParse(decoded);
300
- if (!frame.success)
301
- return;
302
- if (frame.data.type === "channel.registered") {
303
- if (frame.data.sessionId !== parsed.sessionId || frame.data.generation !== generation) {
304
- next.close(1008, "registration mismatch");
305
- return;
306
- }
307
- if (registrationTimer)
308
- clearTimeout(registrationTimer);
309
- channelReady = true;
310
- hasRegisteredOnce = true;
311
- if (active) {
312
- sendActiveLocalUserMessage();
313
- send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
314
- }
315
- for (const interaction of interactions.values())
316
- send(interaction.requestFrame);
317
- if (!settled) {
318
- settled = true;
319
- resolve();
320
- }
321
- return;
322
- }
323
- if (!channelReady)
324
- return;
325
- if (frame.data.type === "channel.prompt") {
326
- if (acceptedRemoteInputs.has(frame.data.requestId)) {
327
- acknowledgeDelivery(frame.data.deliveryId, "accepted");
328
- return;
329
- }
330
- const status = enqueue({ text: frame.data.text, requestId: frame.data.requestId });
331
- if (status === "accepted") {
332
- acceptedRemoteInputs.add(frame.data.requestId);
333
- while (acceptedRemoteInputs.size > MAX_CLAUDE_INPUT_DEDUP_ENTRIES) {
334
- const oldest = acceptedRemoteInputs.values().next().value;
335
- if (!oldest)
336
- break;
337
- acceptedRemoteInputs.delete(oldest);
338
- }
339
- }
340
- acknowledgeDelivery(frame.data.deliveryId, status === "accepted" ? "accepted" : "queue_full");
341
- }
342
- else if (frame.data.type === "bridge.approval_response") {
343
- const accepted = finishInteraction(frame.data.requestId, { decision: frame.data.decision }, false);
344
- acknowledgeDelivery(frame.data.deliveryId, accepted ? "accepted" : "not_pending");
345
- }
346
- else if (frame.data.type === "bridge.question_response") {
347
- const accepted = finishInteraction(frame.data.requestId, { answers: frame.data.answers }, false);
348
- acknowledgeDelivery(frame.data.deliveryId, accepted ? "accepted" : "not_pending");
349
- }
350
- else if (frame.data.type === "channel.permission_response") {
351
- acknowledgeDelivery(frame.data.deliveryId, "not_pending");
352
- }
353
- });
354
- next.once("error", (error) => {
355
- if (!settled) {
356
- settled = true;
357
- reject(error);
358
- }
359
- });
360
- next.on("close", (code) => {
361
- if (registrationTimer)
362
- clearTimeout(registrationTimer);
363
- const wasCurrent = socket === next;
364
- if (wasCurrent) {
365
- socket = null;
366
- channelReady = false;
367
- }
368
- if (code === 4001) {
369
- superseded = true;
370
- process.stderr.write("NowCrew remote: this Claude session was replaced by a newer bridge.\n");
371
- stop();
372
- if (!settled) {
373
- settled = true;
374
- reject(new Error("Claude remote bridge was superseded"));
375
- }
376
- return;
377
- }
378
- if (!settled) {
379
- settled = true;
380
- reject(new Error(`Claude remote gateway closed before registration (${code})`));
381
- }
382
- if (wasCurrent && hasRegisteredOnce && !stopped && shouldReconnectClaudeChannel(code)) {
383
- reconnectTimer = setTimeout(() => { void connectGateway().catch(() => { }); }, 1_000);
384
- }
385
- });
386
- });
387
- const sendNext = () => {
388
- if (active || inputs.length === 0 || stopped)
389
- return;
390
- active = inputs.shift();
391
- assistantPrinted = false;
392
- inputStream.push(userMessage(parsed.sessionId, active.text, active.messageId, active.createdAt));
393
- sendActiveLocalUserMessage();
394
- send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: true });
395
- };
396
- function enqueue(value) {
397
- const status = claudeInputQueueStatus(inputs.length + (active ? 1 : 0), outstandingInputBytes, value.text);
398
- if (status !== "accepted")
399
- return status;
400
- const queued = {
401
- ...value,
402
- byteLength: Buffer.byteLength(value.text, "utf8"),
403
- messageId: (value.requestId ?? randomUUID()),
404
- createdAt: new Date().toISOString(),
405
- };
406
- outstandingInputBytes += queued.byteLength;
407
- inputs.push(queued);
408
- sendNext();
409
- return "accepted";
410
- }
411
- const waitForInteraction = (interaction, render, handleLine) => {
412
- const promise = new Promise((resolve) => {
413
- interactions.set(interaction.id, { ...interaction, resolve });
414
- });
415
- terminalInteractions.push(interaction.id);
416
- render();
417
- terminalHandler = (line) => {
418
- const result = handleLine(line);
419
- if (result)
420
- finishInteraction(interaction.id, result, true);
421
- };
422
- return promise;
423
- };
424
- const canUseTool = async (toolName, input, { signal }) => {
425
- const requestId = randomUUID();
426
- if (toolName === "AskUserQuestion") {
427
- const parsedQuestions = AskQuestionInputSchema.safeParse(input);
428
- if (!parsedQuestions.success)
429
- return { behavior: "deny", message: "Unsupported question format" };
430
- const questions = parsedQuestions.data.questions.map((question, index) => ({
431
- id: `q${index}`,
432
- header: question.header,
433
- question: question.question,
434
- isSecret: false,
435
- options: question.options,
436
- }));
437
- const requestFrame = {
438
- type: "bridge.question",
439
- protocolVersion: REMOTE_PROTOCOL_VERSION,
440
- sessionId: parsed.sessionId,
441
- requestId,
442
- questions,
443
- };
444
- const waiting = waitForInteraction({ id: requestId, kind: "question", questions, requestFrame }, () => {
445
- process.stdout.write("\nInput needed:\n");
446
- for (const [index, question] of questions.entries()) {
447
- process.stdout.write(`${index + 1}. ${question.header}: ${question.question}\n`);
448
- for (const option of question.options)
449
- process.stdout.write(` - ${option.label}: ${option.description}\n`);
450
- }
451
- process.stdout.write("Answer: ");
452
- }, (line) => {
453
- const values = line.split(";").map((value) => value.trim());
454
- return { answers: Object.fromEntries(questions.map((question, index) => [question.id, [values[index] ?? ""]])) };
455
- });
456
- send(requestFrame);
457
- signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
458
- const result = await waiting;
459
- if (!result.answers)
460
- return { behavior: "deny", message: "User input cancelled" };
461
- const answers = Object.fromEntries(questions.map((question) => [
462
- question.question,
463
- result.answers?.[question.id]?.join(", ") ?? "",
464
- ]));
465
- return { behavior: "allow", updatedInput: { ...input, answers } };
466
- }
467
- const requestFrame = {
468
- type: "bridge.approval",
469
- protocolVersion: REMOTE_PROTOCOL_VERSION,
470
- sessionId: parsed.sessionId,
471
- requestId,
472
- tool: toolName,
473
- preview: preview(input),
474
- };
475
- const waiting = waitForInteraction({ id: requestId, kind: "approval", requestFrame }, () => process.stdout.write(`\nApproval required: ${toolName}\n${preview(input)}\nAllow? `), (line) => /^(y|yes|allow)$/i.test(line.trim()) ? { decision: "allow" }
476
- : /^(n|no|deny)$/i.test(line.trim()) ? { decision: "deny" } : null);
477
- send(requestFrame);
478
- signal.addEventListener("abort", () => finishInteraction(requestId, { decision: "deny" }, true), { once: true });
479
- const result = await waiting;
480
- return result.decision === "allow"
481
- ? { behavior: "allow", updatedInput: input }
482
- : { behavior: "deny", message: "User denied this action" };
483
- };
484
- await connectGateway();
485
- if (stopped)
486
- return 1;
487
- const activeConversation = query({
488
- prompt: inputStream,
489
- options: {
490
- cwd: context.cwd,
491
- canUseTool,
492
- settingSources: ["user", "project", "local"],
493
- includePartialMessages: false,
494
- ...(parsed.resume ? { resume: parsed.sessionId } : { sessionId: parsed.sessionId }),
495
- ...parsed.options,
496
- ...(process.env.NOWCREW_CLAUDE_BIN ? { pathToClaudeCodeExecutable: process.env.NOWCREW_CLAUDE_BIN } : {}),
497
- },
498
- });
499
- conversation = activeConversation;
500
- terminal = createInterface({ input: process.stdin, output: process.stdout });
501
- terminal.setPrompt("› ");
502
- terminal.on("line", (line) => {
503
- if (terminalHandler && terminalInteractions.length > 0)
504
- terminalHandler(line);
505
- else if (line.trim()) {
506
- const status = enqueue({ text: line.trim(), requestId: null });
507
- if (status !== "accepted")
508
- process.stderr.write(`NowCrew remote: input rejected (${status}).\n`);
509
- }
510
- terminal.prompt();
511
- });
512
- terminal.on("close", stop);
513
- process.stdout.write(`Claude mobile session ${parsed.sessionId}\n`);
514
- terminal.prompt();
515
- if (parsed.initialPrompt) {
516
- const status = enqueue({ text: parsed.initialPrompt, requestId: null });
517
- if (status !== "accepted")
518
- process.stderr.write(`NowCrew remote: initial input rejected (${status}).\n`);
519
- }
520
- process.once("SIGINT", stop);
521
- process.once("SIGTERM", stop);
522
- try {
523
- for await (const message of activeConversation) {
524
- if (message.type === "assistant")
525
- assistantPrinted = printMessage(message) || assistantPrinted;
526
- else
527
- printMessage(message);
528
- if (message.type !== "result")
529
- continue;
530
- const result = message.subtype === "success" && "result" in message && typeof message.result === "string"
531
- ? message.result.trim()
532
- : "";
533
- if (!assistantPrinted && result)
534
- process.stdout.write(`\n${result}\n`);
535
- const completedInput = active;
536
- if (completedInput)
537
- send({
538
- type: "channel.reply",
539
- protocolVersion: REMOTE_PROTOCOL_VERSION,
540
- sessionId: parsed.sessionId,
541
- requestId: completedInput.messageId,
542
- text: result,
543
- });
544
- if (completedInput)
545
- outstandingInputBytes = Math.max(0, outstandingInputBytes - completedInput.byteLength);
546
- active = null;
547
- send({ type: "bridge.status", protocolVersion: REMOTE_PROTOCOL_VERSION, sessionId: parsed.sessionId, busy: false });
548
- terminal.prompt();
549
- sendNext();
550
- }
551
- return superseded ? 1 : stopped ? 0 : 1;
552
- }
553
- finally {
554
- process.off("SIGINT", stop);
555
- process.off("SIGTERM", stop);
556
- stop();
557
- }
558
- }