dsh-foreman 0.1.1 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -6,6 +6,67 @@
6
6
 
7
7
  本项目自己的版本从 `0.1.0` 开始。
8
8
 
9
+ ## 0.3.1 — 2026-11-08
10
+
11
+ ### 修复:同一 id 的两个连接互相踢,设备永远上不了线
12
+
13
+ `Hub.attach` 在新连接到达时,立刻关闭同 id 的旧连接。设备重连时旧 socket 往往还没超时,
14
+ 于是**两边互相踢**:谁都完不成握手,hub 一直认为设备离线,双方不停重试。
15
+
16
+ 现场证据:本机 89 个 TIME_WAIT,hub 上一个在线设备都没有;一张要查看宿舍电脑的工单,
17
+ 因为选不到设备,回退到服务器本地,在空目录里跑完并报告成功。
18
+
19
+ 现在新连接先排队,**令牌验证通过后才接管 id**。半成品连接打扰不到正在工作的那条,
20
+ 真正的重连仍然能接管。
21
+
22
+ ### 修复:点名了机器,却在服务器上跑
23
+
24
+ `prepareTicket` 在选不到设备时静默回退本地执行,并把这种行为写成了「有意为之」。
25
+ 在「项目只存在于远程设备」的场景里,它给出一个看起来成功、实际毫无意义的结果。
26
+
27
+ 现在:点名了设备或要求了标签的请求,选不到设备就记 `blockedReason`,由 executor
28
+ 直接报 `blocked`,**不启动任何 Session**。人在面板上看到「没有设备能接」,
29
+ 而不是假成功。没点名设备的仍然回退本地——本机可达的项目不该因为笔记本离线就卡住。
30
+
31
+ ### 修复:worker 会话在侧栏看不到
32
+
33
+ 原来把所有 `worker-*` 都藏起来。那恰好藏掉了唯一能回答「任务在哪台机器、哪个目录跑」的线索。
34
+ 现在只藏主会话(用行上的 M 气泡进),worker 保留显示。
35
+
36
+ ### 新增:设备可以删除,也可以改项目
37
+
38
+ - **删除**:吊销保留记录(审计用),删除连记录一起去掉(登记错了用)。删除需要二次确认。
39
+ - **编辑**:显示名、标签、它能接的项目都能改,**id 和令牌不动**,所以那台设备不用重新配置。
40
+ 这解决了「登记时漏了项目只能重登」的问题。
41
+
42
+ ## 0.3.0 — 2026-11-07
43
+
44
+ ### 新增:在面板里登记设备,不用再跑脚本
45
+
46
+ 「设置 › 设备」多了一个登记表单:填 id、显示名、它能接的项目和**那台机器上的路径**,
47
+ 点「登记」就得到一枚令牌。卡片把设备要的四样东西排在一起——`url`、`deviceId`、`token`,
48
+ 以及设备页面的入口——令牌只显示这一次,因为库里只存哈希,关掉或刷新就再也拿不回来。
49
+
50
+ 以前这一步只能跑 `scripts/add-device.mjs`,那个脚本还在,行为不变;面板走的是同一个
51
+ `Devices.add`,所以两条路得到的结果一样。
52
+
53
+ 重复的 id 会回 400 并明确说「去换令牌」,不是 500——那是操作者的失误,不是服务端故障。
54
+
55
+ ### 新增接口
56
+
57
+ `POST /orchestrator/api/devices`,body `{ id, name, labels?, roots }`,
58
+ 响应 `{ id, token }`。令牌只在这里出现一次。
59
+
60
+ ### 修复:bundle patch 里的中文是乱码
61
+
62
+ `cordis.patch.yml` 里几行中文在初始提交时就已经是「UTF-8 字节被当成 GBK 读」的产物,
63
+ 所以面板上主会话旁边显示的是 `dsh-foreman 涓?Session`。已改回正确的
64
+ `dsh-foreman 主 Session` 与 `dsh-foreman 执行器`。
65
+
66
+ ### 依赖
67
+
68
+ `dsh-foreman-node` 精确锁到 `0.2.0`(不写 `^`):两边协议必须同版本,自动升级反而危险。
69
+
9
70
  ## 0.1.1 — 2026-11-07
10
71
 
11
72
  修的是 `0.1.0` 发布时才暴露的两个问题,都是真机验证(上海服务器 srv-02)发现的。
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
File without changes
package/cordis.patch.yml CHANGED
@@ -6,11 +6,11 @@
6
6
  name: dsh-foreman
7
7
  - id: dsh-foreman-github
8
8
  name: dsh-foreman/github-watch
9
- # 椋炰功 IM 鏉ユ簮锛氱兢娑堟伅涓庣鑱婅繘宸ュ崟銆傛病鏈夐厤缃満鍣ㄤ汉 CLI 鏃跺畠鑷繁鎶ャ€屾湭璁剧疆鏈哄櫒浜?CLI銆嶏紝
10
- # 涓嶅奖鍝嶅埆鐨勬潵婧愶紝鎵€浠ラ粯璁ゅ紑鐫€锛岃浜鸿兘鐩存帴鍦ㄨ缃噷濉€?
9
+ # 飞书 IM 来源:群消息与私聊进工单。没有配置机器人 CLI 时它自己报「未设置机器人 CLI」,
10
+ # 不影响别的来源,所以默认开着,让人能直接在设置里填。
11
11
  - id: dsh-foreman-feishu
12
12
  name: dsh-foreman/feishu-watch
13
- # 鏈湴 Source锛氭妸鎿嶄綔鑰呭湪涓讳細璇濋噷璇寸殑璇濆綋浣滈渶姹傛潵婧愶紱涓€涓疄渚嬭鐩栨墍鏈夊伐浣滃尯銆?
13
+ # 本地 Source:把操作者在主会话里说的话当作需求来源;一个实例覆盖所有工作区。
14
14
  - id: dsh-foreman-dsh
15
15
  name: dsh-foreman/local-watch
16
16
  # The agent preset of every Executor Session (WORKER_PRESET in src/app/sessions.ts); the main Session has its own, below.
@@ -24,8 +24,8 @@
24
24
  name: '@deepseek-ai/dsh-agent-preset'
25
25
  config:
26
26
  id: dsh-foreman-worker
27
- name: dsh-foreman 鎵ц鍣?
28
- description: dsh-foreman 鐨勬墽琛屽櫒 Session 涓撶敤锛屼笉渚涙墜鍔ㄩ€夋嫨銆傚墠鍙板懡浠よ秴 5 绉掕浆鍏ュ悗鍙帮紝鍙惈鎵ц宸ヤ綔鎵€闇€鐨勫伐鍏枫€?
27
+ name: dsh-foreman 执行器
28
+ description: dsh-foreman 的执行器 Session 专用,不供手动选择。前台命令超 5 秒转入后台,只含执行工作所需的工具。
29
29
  order: 90
30
30
  plugins:
31
31
  - id: persona
@@ -129,8 +129,8 @@
129
129
  name: '@deepseek-ai/dsh-agent-preset'
130
130
  config:
131
131
  id: dsh-foreman-router
132
- name: dsh-foreman 涓?Session
133
- description: dsh-foreman 鐨勪富 Session 涓撶敤锛屼笉渚涙墜鍔ㄩ€夋嫨銆傚墠鍙板懡浠ゅ拰浠诲姟绛夊緟瓒?5 绉掕浆鍏ュ悗鍙帮紝鍙惈璺敱鎵€闇€鐨勫伐鍏枫€?
132
+ name: dsh-foreman 主 Session
133
+ description: dsh-foreman 的主 Session 专用,不供手动选择。前台命令和任务等待超 5 秒转入后台,只含路由所需的工具。
134
134
  order: 91
135
135
  plugins:
136
136
  - id: agent-instructions
package/icon.svg CHANGED
File without changes
package/lib/client.js CHANGED
@@ -4598,11 +4598,29 @@ window.__ModuleLoader__.load({
4598
4598
  devicesOnline: ["在线", "Online"],
4599
4599
  devicesRunning: ["在跑", "Running"],
4600
4600
  devicesEmpty: ["还没有登记任何设备", "No device is registered yet"],
4601
- devicesEmptyHint: ["在服务器上运行 dsh-foreman 的注册命令生成令牌,再把域名、设备 id 与令牌填进设备自己的配置。", "Mint a token with the hub's registration command, then put the address, device id and token into that device's own configuration."],
4601
+ devicesEmptyHint: ["在下面登记一台设备,它会给出三样东西填进那台设备的配置页。", "Register a machine below; it gives you the three values to put on that machine's own configuration page."],
4602
+ devicesAdd: ["登记一台设备", "Register a machine"],
4603
+ devicesAddHint: ["登记后这台设备才能接单。登记不会连上它——连接由那台设备自己发起。", "A machine takes work only after it is registered here. Registering does not connect it: the machine dials out itself."],
4604
+ devicesAddId: ["设备 id", "Device id"],
4605
+ devicesAddName: ["显示名", "Display name"],
4606
+ devicesAddRoots: ["它能接的项目", "Projects it can take"],
4607
+ devicesAddRoot: ["加一个项目", "Add a project"],
4608
+ devicesAddProject: ["项目名", "Project"],
4609
+ devicesAddPath: ["那台机器上的路径", "Path on that machine"],
4610
+ devicesAddSubmit: ["登记", "Register"],
4611
+ devicesAddMinted: ["把这四样填进那台设备的配置页", "Put these values on that machine's configuration page"],
4612
+ devicesAddMintedHint: ["令牌只显示这一次,库里只存哈希。关掉或刷新这一页就再也拿不回来了;丢了只能换一个。", "The token is shown once; only its hash is stored. Closing or refreshing this page loses it for good, and the only way back is a new one."],
4602
4613
  deviceOnline: ["在线", "Online"],
4603
4614
  deviceRevoked: ["已吊销", "Revoked"],
4604
4615
  deviceRunning: ["在跑 {count} 件", "{count} running"],
4605
4616
  deviceLastSeen: ["最近活动", "Last seen"],
4617
+ deviceEdit: ["编辑", "Edit"],
4618
+ deviceEditHint: ["改显示名、标签和能接的项目。id 和令牌不在这里改:id 已经写在那台机器的配置里,换令牌要先把新令牌给它。", "Change the display name, labels and projects. The id and token are not editable here: the id is already in that machine's configuration, and a new token must be handed to it first."],
4619
+ deviceLabels: ["标签", "Labels"],
4620
+ deviceLabelsHint: ["逗号分隔。群聊可以要求某个标签,只有带这个标签的设备才接得到。", "Comma separated. A chat can require a label, so only devices carrying it can take that work."],
4621
+ devicePathKeep: ["留空即保持原路径", "Leave empty to keep the current path"],
4622
+ deviceDelete: ["删除", "Delete"],
4623
+ deviceDeleteConfirm: ["确认删除", "Confirm delete"],
4606
4624
  deviceRevoke: ["吊销", "Revoke"],
4607
4625
  deviceRevokeConfirm: ["确认吊销", "Confirm revoke"],
4608
4626
  deviceRotate: ["换令牌", "New token"],
@@ -5545,11 +5563,12 @@ window.__ModuleLoader__.load({
5545
5563
  //#endregion
5546
5564
  //#region src/client/devices.tsx
5547
5565
  /** Devices settings group: which machines may take work, whether each is online, and what it is running. */
5548
- /** The device list, read-only apart from revoking and rotating a token. */
5566
+ /** The device list, the registration form, and the two token actions. */
5549
5567
  function DevicesSettings() {
5550
5568
  const { t, call } = usePanel();
5551
5569
  const { data, error, reload } = usePoll(() => call("GET devices"), []);
5552
5570
  const [rotated, setRotated] = (0, react.useState)();
5571
+ const [editing, setEditing] = (0, react.useState)();
5553
5572
  const revoke = async (id) => {
5554
5573
  await call("POST devices/:id/revoke", { id });
5555
5574
  await reload();
@@ -5561,6 +5580,10 @@ window.__ModuleLoader__.load({
5561
5580
  });
5562
5581
  await reload();
5563
5582
  };
5583
+ const remove = async (id) => {
5584
+ await call("DELETE devices/:id", { id });
5585
+ await reload();
5586
+ };
5564
5587
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
5565
5588
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PageHead, {
5566
5589
  back: false,
@@ -5585,6 +5608,9 @@ window.__ModuleLoader__.load({
5585
5608
  })
5586
5609
  ]
5587
5610
  }),
5611
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AddDevice, { onAdded: () => {
5612
+ reload();
5613
+ } }),
5588
5614
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Section, {
5589
5615
  title: t("devicesList"),
5590
5616
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ListSection, {
@@ -5601,11 +5627,20 @@ window.__ModuleLoader__.load({
5601
5627
  children: data.rows.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceLine, {
5602
5628
  row,
5603
5629
  onRevoke: () => revoke(row.id),
5604
- onRotate: () => rotate(row.id)
5630
+ onRotate: () => rotate(row.id),
5631
+ onEdit: () => setEditing(row),
5632
+ onDelete: () => remove(row.id)
5605
5633
  }, row.id))
5606
5634
  }) : void 0
5607
5635
  })
5608
5636
  }),
5637
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(EditDevice, {
5638
+ row: editing,
5639
+ onClose: () => setEditing(void 0),
5640
+ onSaved: () => {
5641
+ reload();
5642
+ }
5643
+ }),
5609
5644
  rotated && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5610
5645
  title: t("devicesRotated"),
5611
5646
  tone: "warn",
@@ -5624,10 +5659,11 @@ window.__ModuleLoader__.load({
5624
5659
  })
5625
5660
  ] });
5626
5661
  }
5627
- /** One device: name, online dot, load, project names, last seen, and the two token actions. */
5628
- function DeviceLine({ row, onRevoke, onRotate }) {
5662
+ /** One device: name, online dot, load, project names, last seen, and the actions an operator needs. */
5663
+ function DeviceLine({ row, onRevoke, onRotate, onEdit, onDelete }) {
5629
5664
  const { t } = usePanel();
5630
5665
  const [confirming, setConfirming] = (0, react.useState)(false);
5666
+ const [deleting, setDeleting] = (0, react.useState)(false);
5631
5667
  const tone = row.revoked ? "idle" : row.online ? "done" : "warning";
5632
5668
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
5633
5669
  className: "dt-row",
@@ -5669,34 +5705,310 @@ window.__ModuleLoader__.load({
5669
5705
  })]
5670
5706
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5671
5707
  className: "dt-row-actions",
5672
- children: [!row.revoked && (confirming ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5673
- variant: "danger",
5674
- size: "sm",
5675
- onClick: () => {
5676
- setConfirming(false);
5677
- onRevoke();
5678
- },
5679
- children: t("deviceRevokeConfirm")
5680
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5681
- size: "sm",
5682
- onClick: () => setConfirming(false),
5683
- children: t("cancel")
5684
- })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5685
- variant: "ghost",
5686
- size: "sm",
5687
- onClick: () => setConfirming(true),
5688
- children: t("deviceRevoke")
5689
- })), !row.revoked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5690
- variant: "ghost",
5691
- size: "sm",
5692
- onClick: () => {
5693
- onRotate();
5694
- },
5695
- children: t("deviceRotate")
5696
- })]
5708
+ children: [
5709
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5710
+ variant: "ghost",
5711
+ size: "sm",
5712
+ onClick: onEdit,
5713
+ children: t("deviceEdit")
5714
+ }),
5715
+ !row.revoked && (confirming ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5716
+ variant: "danger",
5717
+ size: "sm",
5718
+ onClick: () => {
5719
+ setConfirming(false);
5720
+ onRevoke();
5721
+ },
5722
+ children: t("deviceRevokeConfirm")
5723
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5724
+ size: "sm",
5725
+ onClick: () => setConfirming(false),
5726
+ children: t("cancel")
5727
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5728
+ variant: "ghost",
5729
+ size: "sm",
5730
+ onClick: () => setConfirming(true),
5731
+ children: t("deviceRevoke")
5732
+ })),
5733
+ !row.revoked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5734
+ variant: "ghost",
5735
+ size: "sm",
5736
+ onClick: () => {
5737
+ onRotate();
5738
+ },
5739
+ children: t("deviceRotate")
5740
+ }),
5741
+ deleting ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5742
+ variant: "danger",
5743
+ size: "sm",
5744
+ onClick: () => {
5745
+ setDeleting(false);
5746
+ onDelete();
5747
+ },
5748
+ children: t("deviceDeleteConfirm")
5749
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5750
+ size: "sm",
5751
+ onClick: () => setDeleting(false),
5752
+ children: t("cancel")
5753
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5754
+ variant: "ghost",
5755
+ size: "sm",
5756
+ onClick: () => setDeleting(true),
5757
+ children: t("deviceDelete")
5758
+ })
5759
+ ]
5697
5760
  })]
5698
5761
  });
5699
5762
  }
5763
+ /**
5764
+ * Registering a machine, in place.
5765
+ *
5766
+ * The three values the machine needs are shown together and once: the hub keeps only a token hash, so a token that
5767
+ * leaves this card is gone. That is why the card is not dismissible: closing it is losing the token, and the page
5768
+ * says so instead of letting someone find out at the device.
5769
+ */
5770
+ function AddDevice({ onAdded }) {
5771
+ const { t, call } = usePanel();
5772
+ const { data: state } = usePoll(() => call("GET state"), []);
5773
+ const [id, setId] = (0, react.useState)("");
5774
+ const [name, setName] = (0, react.useState)("");
5775
+ const [roots, setRoots] = (0, react.useState)([]);
5776
+ const [minted, setMinted] = (0, react.useState)();
5777
+ const [refusal, setRefusal] = (0, react.useState)();
5778
+ const [busy, setBusy] = (0, react.useState)(false);
5779
+ const register = async () => {
5780
+ setBusy(true);
5781
+ setRefusal(void 0);
5782
+ try {
5783
+ const result = await call("POST devices", { body: {
5784
+ id: id.trim(),
5785
+ name: name.trim(),
5786
+ labels: [],
5787
+ roots: roots.map((root) => ({
5788
+ project: root.project.trim(),
5789
+ path: root.path.trim()
5790
+ }))
5791
+ } });
5792
+ setMinted({
5793
+ id: result.id,
5794
+ token: result.token
5795
+ });
5796
+ setId("");
5797
+ setName("");
5798
+ setRoots([]);
5799
+ onAdded();
5800
+ } catch (error) {
5801
+ setRefusal(errorText(error));
5802
+ } finally {
5803
+ setBusy(false);
5804
+ }
5805
+ };
5806
+ const edit = (index, patch) => {
5807
+ setRoots((current) => current.map((root, at) => at === index ? {
5808
+ ...root,
5809
+ ...patch
5810
+ } : root));
5811
+ };
5812
+ const hubPort = state?.setup.machine.hubPort ?? 0;
5813
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5814
+ title: t("devicesAdd"),
5815
+ hint: t("devicesAddHint"),
5816
+ children: [
5817
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(FieldGrid, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5818
+ label: t("devicesAddId"),
5819
+ required: true,
5820
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5821
+ value: id,
5822
+ onChange: (event) => setId(event.target.value),
5823
+ placeholder: "laptop"
5824
+ })
5825
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5826
+ label: t("devicesAddName"),
5827
+ required: true,
5828
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5829
+ value: name,
5830
+ onChange: (event) => setName(event.target.value)
5831
+ })
5832
+ })] }),
5833
+ roots.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldGrid, { children: roots.map((root, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5834
+ label: index === 0 ? t("devicesAddProject") : `${t("devicesAddProject")} ${index + 1}`,
5835
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5836
+ value: root.project,
5837
+ onChange: (event) => edit(index, { project: event.target.value }),
5838
+ placeholder: "shop"
5839
+ })
5840
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5841
+ label: index === 0 ? t("devicesAddPath") : `${t("devicesAddPath")} ${index + 1}`,
5842
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5843
+ value: root.path,
5844
+ onChange: (event) => edit(index, { path: event.target.value }),
5845
+ placeholder: "F:\\\\dsh\\\\shop"
5846
+ })
5847
+ })] }, index)) }),
5848
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5849
+ className: "dt-form-actions",
5850
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5851
+ size: "sm",
5852
+ onClick: () => setRoots((current) => [...current, {
5853
+ project: "",
5854
+ path: ""
5855
+ }]),
5856
+ children: t("devicesAddRoot")
5857
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5858
+ size: "sm",
5859
+ variant: "primary",
5860
+ busy,
5861
+ disabled: id.trim() === "" || name.trim() === "",
5862
+ onClick: () => {
5863
+ register();
5864
+ },
5865
+ children: t("devicesAddSubmit")
5866
+ })]
5867
+ }),
5868
+ refusal !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorLine, {
5869
+ error: refusal,
5870
+ title: t("loadFailed")
5871
+ })
5872
+ ]
5873
+ }), minted && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5874
+ title: t("devicesAddMinted"),
5875
+ tone: "warn",
5876
+ children: [
5877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Hint, { children: t("devicesAddMintedHint") }),
5878
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(KeyValues, { rows: mintedRows(minted, hubPort) }),
5879
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("code", {
5880
+ className: "dt-mono",
5881
+ "data-device-token": true,
5882
+ children: minted.token
5883
+ })
5884
+ ]
5885
+ })] });
5886
+ }
5887
+ /**
5888
+ * The rows a device needs, with the hub's real port.
5889
+ *
5890
+ * A hub with `hubPort: 0` has its listener switched off, so no address a card could print would work; saying so is
5891
+ * more use than a plausible-looking port that nothing listens on.
5892
+ */
5893
+ function mintedRows(minted, hubPort) {
5894
+ return [
5895
+ ["url", hubPort > 0 ? `ws://127.0.0.1:${hubPort}` : "the hub is off: set hubPort in the plugin configuration"],
5896
+ ["deviceId", minted.id],
5897
+ ["token", minted.token]
5898
+ ];
5899
+ }
5900
+ /**
5901
+ * Editing what a device offers: its display name, its labels, and the projects it can work on.
5902
+ *
5903
+ * The id and the token are not editable here on purpose. The id is what the device's own configuration already says,
5904
+ * and changing it would need the machine in front of you; a token is rotated deliberately, because the machine must
5905
+ * be given the new one before it can connect again.
5906
+ */
5907
+ function EditDevice({ row, onClose, onSaved }) {
5908
+ const { t, call } = usePanel();
5909
+ const [name, setName] = (0, react.useState)(row.name);
5910
+ const [labels, setLabels] = (0, react.useState)(row.labels.join(", "));
5911
+ const [roots, setRoots] = (0, react.useState)(row.projects.map((project) => ({
5912
+ project,
5913
+ path: ""
5914
+ })));
5915
+ const [refusal, setRefusal] = (0, react.useState)();
5916
+ const [busy, setBusy] = (0, react.useState)(false);
5917
+ const save = async () => {
5918
+ setBusy(true);
5919
+ setRefusal(void 0);
5920
+ try {
5921
+ await call("PUT devices/:id", {
5922
+ id: row.id,
5923
+ body: {
5924
+ name: name.trim(),
5925
+ labels: labels.split(",").map((label) => label.trim()).filter(Boolean),
5926
+ roots: roots.filter((root) => root.project.trim() && root.path.trim()).map((root) => ({
5927
+ project: root.project.trim(),
5928
+ path: root.path.trim()
5929
+ }))
5930
+ }
5931
+ });
5932
+ onSaved();
5933
+ onClose();
5934
+ } catch (error) {
5935
+ setRefusal(errorText(error));
5936
+ } finally {
5937
+ setBusy(false);
5938
+ }
5939
+ };
5940
+ const edit = (index, patch) => setRoots((current) => current.map((root, at) => at === index ? {
5941
+ ...root,
5942
+ ...patch
5943
+ } : root));
5944
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(Card, {
5945
+ title: `${t("deviceEdit")} · ${row.id}`,
5946
+ hint: t("deviceEditHint"),
5947
+ children: [
5948
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(FieldGrid, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5949
+ label: t("devicesAddName"),
5950
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5951
+ value: name,
5952
+ onChange: (event) => setName(event.target.value)
5953
+ })
5954
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5955
+ label: t("deviceLabels"),
5956
+ note: t("deviceLabelsHint"),
5957
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5958
+ value: labels,
5959
+ onChange: (event) => setLabels(event.target.value),
5960
+ placeholder: "intranet, office"
5961
+ })
5962
+ })] }),
5963
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FieldGrid, { children: roots.map((root, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5964
+ label: `${t("devicesAddProject")} ${index + 1}`,
5965
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5966
+ value: root.project,
5967
+ onChange: (event) => edit(index, { project: event.target.value })
5968
+ })
5969
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Field, {
5970
+ label: `${t("devicesAddPath")} ${index + 1}`,
5971
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Input, {
5972
+ value: root.path,
5973
+ onChange: (event) => edit(index, { path: event.target.value }),
5974
+ placeholder: t("devicePathKeep")
5975
+ })
5976
+ })] }, index)) }),
5977
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5978
+ className: "dt-form-actions",
5979
+ children: [
5980
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5981
+ size: "sm",
5982
+ onClick: () => setRoots((current) => [...current, {
5983
+ project: "",
5984
+ path: ""
5985
+ }]),
5986
+ "data-edit-root": "1",
5987
+ children: t("devicesAddRoot")
5988
+ }),
5989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5990
+ size: "sm",
5991
+ variant: "primary",
5992
+ busy,
5993
+ onClick: () => {
5994
+ save();
5995
+ },
5996
+ children: t("save")
5997
+ }),
5998
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Button, {
5999
+ size: "sm",
6000
+ onClick: onClose,
6001
+ children: t("cancel")
6002
+ })
6003
+ ]
6004
+ }),
6005
+ refusal !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ErrorLine, {
6006
+ error: refusal,
6007
+ title: t("loadFailed")
6008
+ })
6009
+ ]
6010
+ });
6011
+ }
5700
6012
  //#endregion
5701
6013
  //#region src/client/logs.tsx
5702
6014
  /**
@@ -8176,10 +8488,15 @@ window.__ModuleLoader__.load({
8176
8488
  return key.startsWith("workspace:") ? key.slice(10) : void 0;
8177
8489
  };
8178
8490
  /**
8179
- * 插件自己建的会话:`worker-`(每张工单的执行者)与 `orchestrator-`(主会话与评估)。
8180
- * 它们的生命周期由我们管,不该再占侧栏一行 —— 人要进主会话,用行上的 M 气泡。
8491
+ * 插件自己建的会话里,只有主会话被藏起来。
8492
+ *
8493
+ * `orchestrator-` 是主会话本身,人用行上的 M 气泡进,侧栏再列一行就是重复。
8494
+ *
8495
+ * `worker-` 以前也藏,理由是「数量会多」。但那让人失去唯一的线索:任务到底在哪台机器、哪个目录里跑。
8496
+ * 一张工单派到别的设备时,设备上那条会话就是答案,而它恰恰被藏掉了。现在保留显示:多一行,
8497
+ * 换来看得见工作发生的地方。
8181
8498
  */
8182
- const ours = (id) => id.startsWith("worker-") || id.startsWith("orchestrator-");
8499
+ const ours = (id) => id.startsWith("orchestrator-");
8183
8500
  const rowId = (row) => (row.getAttribute("data-row-key") ?? "").replace(/^session:/, "");
8184
8501
  /**
8185
8502
  * 收起插件自己的会话行,以及状态目录那一个伪工作区行。
@@ -1,5 +1,5 @@
1
- import { G as Trigger, L as Thread } from "./intake-Di3PgVj2.js";
2
- import { g as Tally, h as SourceRegistry, n as SourceContext, t as SourceKind } from "./kind-DGutnJ2b.js";
1
+ import { G as Trigger, L as Thread } from "./intake-B1UsoHuw.js";
2
+ import { g as Tally, h as SourceRegistry, n as SourceContext, t as SourceKind } from "./kind-BYtP14oM.js";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
  import { z } from "zod";
5
5
  //#region src/sources/feishu-read.d.ts
@@ -1,2 +1,2 @@
1
- import { a as apply, c as name, d as pollThreads, f as pollTicketPulls, i as Retry, l as pollFailure, m as commentTrigger, o as githubKind, p as GithubComment, r as GithubConfig, s as inject, u as pollRound } from "./kind-DGutnJ2b.js";
1
+ import { a as apply, c as name, d as pollThreads, f as pollTicketPulls, i as Retry, l as pollFailure, m as commentTrigger, o as githubKind, p as GithubComment, r as GithubConfig, s as inject, u as pollRound } from "./kind-BYtP14oM.js";
2
2
  export { type GithubComment, GithubConfig, Retry, apply, commentTrigger, githubKind, inject, name, pollFailure, pollRound, pollThreads, pollTicketPulls };
package/lib/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as reached, A as LOCAL_TARGET, B as ThreadRef, C as ChannelRef, D as Execution, E as Evaluation, F as Strength, G as Trigger, H as TicketEndBy, I as Text, J as followupOwners, K as TriggerDisposition, L as Thread, M as MandateBasis, N as PolicyEffect, O as Follow, P as ReportState, Q as liveTicket, R as ThreadFact, S as Attention, T as DecisionKind, U as TicketInput, V as Ticket, W as TicketOutcome, X as latestAssignment, Y as isoTime, Z as latestReport, _ as ActorRef, a as DomainEvent, at as ticketOutcome, b as AssignmentState, c as GateResult, ct as Reason, d as Policy, dt as said, et as readUntil, f as ReplyStatus, ft as Config, g as AcceptMode, h as SourceScope, i as Candidate, it as threadKey, j as Mandate, k as FollowupRecords, l as OutboundMessage, lt as ReasonCode, m as ScopeResult, mt as DeploymentConfig, n as AssessmentOutcome, nt as settled, o as DomainEvents, ot as waitsForPerson, p as Repositories, pt as Deployment, q as assignmentTargetOf, r as Assessor, rt as targetOf, s as Executor, st as REASONS, t as ReceiveResult, tt as runningAssignment, u as Outbox, ut as reasonText, v as AdmissionCell, w as Decision, x as AssignmentTarget, y as Assignment, z as ThreadFacts } from "./intake-Di3PgVj2.js";
1
+ import { $ as reached, A as LOCAL_TARGET, B as ThreadRef, C as ChannelRef, D as Execution, E as Evaluation, F as Strength, G as Trigger, H as TicketEndBy, I as Text, J as followupOwners, K as TriggerDisposition, L as Thread, M as MandateBasis, N as PolicyEffect, O as Follow, P as ReportState, Q as liveTicket, R as ThreadFact, S as Attention, T as DecisionKind, U as TicketInput, V as Ticket, W as TicketOutcome, X as latestAssignment, Y as isoTime, Z as latestReport, _ as ActorRef, a as DomainEvent, at as ticketOutcome, b as AssignmentState, c as GateResult, ct as Reason, d as Policy, dt as said, et as readUntil, f as ReplyStatus, ft as Config, g as AcceptMode, h as SourceScope, i as Candidate, it as threadKey, j as Mandate, k as FollowupRecords, l as OutboundMessage, lt as ReasonCode, m as ScopeResult, mt as DeploymentConfig, n as AssessmentOutcome, nt as settled, o as DomainEvents, ot as waitsForPerson, p as Repositories, pt as Deployment, q as assignmentTargetOf, r as Assessor, rt as targetOf, s as Executor, st as REASONS, t as ReceiveResult, tt as runningAssignment, u as Outbox, ut as reasonText, v as AdmissionCell, w as Decision, x as AssignmentTarget, y as Assignment, z as ThreadFacts } from "./intake-B1UsoHuw.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  //#region src/core/part.d.ts
4
4
  interface EngineOptions {
package/lib/index.js CHANGED
@@ -5491,6 +5491,13 @@ var DshExecutor = class {
5491
5491
  const ticket = this.host.store.tickets.get(ticketId);
5492
5492
  const assignment = ticket && latestAssignment(ticket);
5493
5493
  if (!ticket || !liveTicket(ticket) || assignment?.id !== assignmentId || assignment.state !== "pending") return;
5494
+ if (ticket.blockedReason !== void 0) {
5495
+ this.host.engine.report(ticketId, {
5496
+ state: "blocked",
5497
+ summary: ticket.blockedReason
5498
+ });
5499
+ return;
5500
+ }
5494
5501
  this.slots.reserve(ticketId, "launch");
5495
5502
  try {
5496
5503
  await this.launch(ticket, assignment);
@@ -8802,6 +8809,10 @@ var RouteError = class extends Error {
8802
8809
  this.status = status;
8803
8810
  }
8804
8811
  };
8812
+ /** Whether a registration failed because the id is taken, the only failure that is the caller's to fix. */
8813
+ function isDuplicateDevice(error) {
8814
+ return error instanceof Error && /is already registered/.test(error.message);
8815
+ }
8805
8816
  async function readBody(req, maxBytes = 131072) {
8806
8817
  let size = 0;
8807
8818
  const chunks = [];
@@ -8924,12 +8935,13 @@ function failure(error, internal) {
8924
8935
  id
8925
8936
  }];
8926
8937
  }
8927
- function machineView({ stateDir, promptMaxChars, admissionContextMaxPages, admissionContextTimeoutMs }) {
8938
+ function machineView({ stateDir, promptMaxChars, admissionContextMaxPages, admissionContextTimeoutMs, hubPort }) {
8928
8939
  return {
8929
8940
  stateDir,
8930
8941
  promptMaxChars: promptMaxChars.get(),
8931
8942
  admissionContextMaxPages: admissionContextMaxPages.get(),
8932
- admissionContextTimeoutMs: admissionContextTimeoutMs.get()
8943
+ admissionContextTimeoutMs: admissionContextTimeoutMs.get(),
8944
+ hubPort
8933
8945
  };
8934
8946
  }
8935
8947
  /** Every setup problem the panel shows: global first, then the ones that pause a single Source, each with that Source's title. */
@@ -9169,11 +9181,51 @@ function routes(runtime) {
9169
9181
  devices: runtime.devices(),
9170
9182
  online: runtime.onlineDevices()
9171
9183
  }),
9184
+ /** 登记一台新设备。令牌只在这一次响应里出现,刷新就没了。 */
9185
+ "POST devices": async (input) => {
9186
+ const body = z.object({
9187
+ id: z.string().trim().min(1),
9188
+ name: z.string().trim().min(1),
9189
+ labels: z.array(z.string().trim().min(1)).optional(),
9190
+ roots: z.array(z.object({
9191
+ project: z.string().trim().min(1),
9192
+ path: z.string().trim().min(1)
9193
+ }))
9194
+ }).parse(await input.body());
9195
+ try {
9196
+ return runtime.addDevice(body);
9197
+ } catch (error) {
9198
+ if (isDuplicateDevice(error)) throw new RouteError(400, error.message);
9199
+ throw error;
9200
+ }
9201
+ },
9172
9202
  /** 吊销一台设备:登记保留(审计要看),下一次心跳就断开。 */
9173
9203
  "POST devices/:id/revoke": ({ params }) => {
9174
9204
  runtime.revokeDevice(decodeURIComponent(params.id));
9175
9205
  return { revoked: true };
9176
9206
  },
9207
+ /** 删掉一台设备。和吊销不同:吊销留着记录,删除连记录一起去掉。 */
9208
+ "DELETE devices/:id": ({ params }) => {
9209
+ runtime.deleteDevice(decodeURIComponent(params.id));
9210
+ return { deleted: true };
9211
+ },
9212
+ /** 改一台设备的名字、标签或项目;id 与令牌不动,所以那台设备不用重新配置。 */
9213
+ "PUT devices/:id": async ({ params, body: readBody }) => {
9214
+ const body = z.object({
9215
+ name: z.string().trim().min(1).optional(),
9216
+ labels: z.array(z.string().trim().min(1)).optional(),
9217
+ roots: z.array(z.object({
9218
+ project: z.string().trim().min(1),
9219
+ path: z.string().trim().min(1)
9220
+ })).optional()
9221
+ }).parse(await readBody());
9222
+ try {
9223
+ runtime.updateDevice(decodeURIComponent(params.id), body);
9224
+ } catch (error) {
9225
+ throw new RouteError(400, error instanceof Error ? error.message : String(error));
9226
+ }
9227
+ return { updated: true };
9228
+ },
9177
9229
  /** 轮换令牌:旧令牌立刻失效,新令牌只返回这一次。 */
9178
9230
  "POST devices/:id/rotate": ({ params }) => ({ token: runtime.rotateDeviceToken(decodeURIComponent(params.id)) }),
9179
9231
  "GET prompts": ({ query }) => {
@@ -9481,6 +9533,36 @@ var Devices = class {
9481
9533
  revokedAt: (/* @__PURE__ */ new Date()).toISOString()
9482
9534
  });
9483
9535
  }
9536
+ /**
9537
+ * Delete a device outright, revoked or not.
9538
+ *
9539
+ * Revoking keeps the record for the audit trail; deleting is for the mistake — a device registered under the wrong
9540
+ * id, or one that will never come back — that an operator does not want cluttering the list forever. The two are
9541
+ * deliberately separate actions: revoke is the safe one, delete is the one that loses the history.
9542
+ *
9543
+ * @throws When the device is unknown, so a typo is reported rather than silently removing nothing.
9544
+ */
9545
+ delete(id) {
9546
+ this.require(id);
9547
+ this.store.delete(KV.foreman.device(id));
9548
+ }
9549
+ /**
9550
+ * Replace what a device offers: its display name, its labels, and the projects it can work on.
9551
+ *
9552
+ * The id and the token are not touched: an id is what the device's own configuration already says, and rotating a
9553
+ * token is a separate decision with its own effect (the device must be given the new one). A handshake in flight
9554
+ * keeps using the record it read, which is why the hub drops the connection after a change.
9555
+ *
9556
+ * @throws When the device is unknown.
9557
+ */
9558
+ update(id, change) {
9559
+ const next = { ...this.require(id) };
9560
+ if (change.name !== void 0) next.name = change.name;
9561
+ if (change.labels !== void 0) next.labels = [...change.labels];
9562
+ if (change.roots !== void 0) next.roots = change.roots.map((root) => ({ ...root }));
9563
+ this.store.set(KV.foreman.device(id), next);
9564
+ return next;
9565
+ }
9484
9566
  /** Whether a token is this device's and the device is still allowed to connect; `false` for an unknown or revoked id. */
9485
9567
  verify(id, token) {
9486
9568
  const device = this.get(id);
@@ -9766,6 +9848,8 @@ var ForemanExecutor = class {
9766
9848
  */
9767
9849
  var Hub = class {
9768
9850
  connections = /* @__PURE__ */ new Map();
9851
+ /** Connections that have arrived but not yet verified: parked so they cannot disturb an established one. */
9852
+ pending = /* @__PURE__ */ new Map();
9769
9853
  devices;
9770
9854
  onFrame;
9771
9855
  log;
@@ -9776,9 +9860,16 @@ var Hub = class {
9776
9860
  this.log = input.log ?? (() => {});
9777
9861
  this.now = input.now ?? (() => (/* @__PURE__ */ new Date()).toISOString());
9778
9862
  }
9779
- /** Register a connection under the device id the transport says it belongs to; it is a claim until `hello`. */
9863
+ /**
9864
+ * Register a connection under the device id the transport says it belongs to; it is a claim until `hello`.
9865
+ *
9866
+ * A new connection does NOT evict an established one here. Evicting on arrival let two clients using the same id
9867
+ * close each other in a loop — each reconnect killed the other's socket, neither ever finished its handshake, and
9868
+ * the device stayed offline forever while both kept retrying. Instead a fresh connection waits in `pending` until
9869
+ * its `hello` verifies, and only then replaces whoever held the id (`admit`). A half-finished connection therefore
9870
+ * cannot disturb a working one, while a genuine reconnect still takes over once it has proved itself.
9871
+ */
9780
9872
  attach(deviceId, channel) {
9781
- this.connections.get(deviceId)?.channel.close();
9782
9873
  const connection = {
9783
9874
  channel,
9784
9875
  handshook: false,
@@ -9786,12 +9877,23 @@ var Hub = class {
9786
9877
  lastSeenAt: this.now(),
9787
9878
  dedupe: new FrameDedupe(/* @__PURE__ */ new Set())
9788
9879
  };
9789
- this.connections.set(deviceId, connection);
9880
+ this.pending.set(channel, {
9881
+ deviceId,
9882
+ connection
9883
+ });
9790
9884
  channel.onMessage((text) => this.receive(deviceId, connection, text));
9791
9885
  channel.onClose(() => {
9886
+ this.pending.delete(channel);
9792
9887
  if (this.connections.get(deviceId) === connection) this.connections.delete(deviceId);
9793
9888
  });
9794
9889
  }
9890
+ /** Promote a verified connection to be the one the id answers to, replacing whatever held it. */
9891
+ admit(deviceId, connection) {
9892
+ const previous = this.connections.get(deviceId);
9893
+ if (previous !== void 0 && previous !== connection) previous.channel.close();
9894
+ this.connections.set(deviceId, connection);
9895
+ this.pending.delete(connection.channel);
9896
+ }
9795
9897
  /** Drop a device's connection and stop listing it as online. */
9796
9898
  detach(deviceId) {
9797
9899
  const connection = this.connections.get(deviceId);
@@ -9832,6 +9934,8 @@ var Hub = class {
9832
9934
  }
9833
9935
  /** Close every device connection, used at shutdown so in-flight work fails fast instead of hanging. */
9834
9936
  closeAll() {
9937
+ for (const [, parked] of [...this.pending]) parked.connection.channel.close();
9938
+ this.pending.clear();
9835
9939
  for (const [id, connection] of [...this.connections]) {
9836
9940
  this.connections.delete(id);
9837
9941
  connection.channel.close();
@@ -9892,6 +9996,7 @@ var Hub = class {
9892
9996
  connection.running = [...frame.running];
9893
9997
  connection.runningCount = connection.running.length;
9894
9998
  connection.lastSeenAt = this.now();
9999
+ this.admit(deviceId, connection);
9895
10000
  this.devices.seen(deviceId, connection.lastSeenAt);
9896
10001
  this.onFrame(deviceId, frame);
9897
10002
  }
@@ -9906,6 +10011,7 @@ var Hub = class {
9906
10011
  }));
9907
10012
  } catch {}
9908
10013
  this.log(`foreman hub refused a device: ${reason}`);
10014
+ this.pending.delete(connection.channel);
9909
10015
  connection.channel.close();
9910
10016
  for (const [id, current] of this.connections) if (current === connection) this.connections.delete(id);
9911
10017
  }
@@ -10157,6 +10263,14 @@ function prepareTicketWithForeman(input) {
10157
10263
  const labels = input.labelsOf?.(trigger);
10158
10264
  if (labels !== void 0 && labels.length > 0) request.labels = labels;
10159
10265
  const target = select(request, input.devices(), input.online());
10266
+ const mustBeRemote = request.device !== void 0 || request.labels !== void 0 && request.labels.length > 0;
10267
+ if (target === void 0 && mustBeRemote) {
10268
+ const blockedReason = `no online device can take this: it asked for ${request.device !== void 0 ? `device "${request.device}"` : "a restricted machine"} on project "${request.project}"; nothing was run`;
10269
+ return {
10270
+ ...ticket,
10271
+ blockedReason
10272
+ };
10273
+ }
10160
10274
  const prepared = input.local({
10161
10275
  ...ticket,
10162
10276
  ...target === void 0 ? {} : { target }
@@ -12376,6 +12490,14 @@ var Runtime = class extends Service {
12376
12490
  models: () => this.ctx.sessionController.modelCatalog(),
12377
12491
  devices: () => runtime.devices.list(),
12378
12492
  onlineDevices: () => runtime.hub?.snapshot() ?? [],
12493
+ /** 登记一台设备并铸一枚令牌;和 `scripts/add-device.mjs` 走同一个 `Devices.add`。 */
12494
+ addDevice: (input) => {
12495
+ const { device, token } = runtime.devices.add(input);
12496
+ return {
12497
+ id: device.id,
12498
+ token
12499
+ };
12500
+ },
12379
12501
  revokeDevice: (id) => {
12380
12502
  runtime.devices.revoke(id);
12381
12503
  runtime.hub?.detach(id);
@@ -12384,6 +12506,14 @@ var Runtime = class extends Service {
12384
12506
  const token = runtime.devices.rotate(id);
12385
12507
  runtime.hub?.detach(id);
12386
12508
  return token;
12509
+ },
12510
+ deleteDevice: (id) => {
12511
+ runtime.hub?.detach(id);
12512
+ runtime.devices.delete(id);
12513
+ },
12514
+ updateDevice: (id, change) => {
12515
+ runtime.devices.update(id, change);
12516
+ runtime.hub?.detach(id);
12387
12517
  }
12388
12518
  };
12389
12519
  }
@@ -485,6 +485,14 @@ interface Ticket {
485
485
  * It lives on the Ticket because `newAssignment` never sees the accept outcome that made the choice.
486
486
  */
487
487
  target?: AssignmentTarget;
488
+ /**
489
+ * Why this ticket cannot start, when its acceptance required a machine and none could take it.
490
+ *
491
+ * Set instead of throwing: the request is real and worth recording, but running it on the host would answer a
492
+ * different question than the one asked. The executor reports it `blocked` without starting a Session, so it
493
+ * reaches a person as a visible failure rather than a silent success somewhere else.
494
+ */
495
+ blockedReason?: string;
488
496
  inputs: TicketInput[];
489
497
  /** Inputs not yet handed to a running assignment. */
490
498
  undelivered: string[];
@@ -1,4 +1,4 @@
1
- import { B as ThreadRef, C as ChannelRef, G as Trigger, L as Thread, R as ThreadFact, V as Ticket, h as SourceScope, l as OutboundMessage, m as ScopeResult, pt as Deployment, t as ReceiveResult, v as AdmissionCell } from "./intake-Di3PgVj2.js";
1
+ import { B as ThreadRef, C as ChannelRef, G as Trigger, L as Thread, R as ThreadFact, V as Ticket, h as SourceScope, l as OutboundMessage, m as ScopeResult, pt as Deployment, t as ReceiveResult, v as AdmissionCell } from "./intake-B1UsoHuw.js";
2
2
  import { Context } from "@deepseek-ai/cordis";
3
3
  import { z } from "zod";
4
4
  import { DatabaseSync } from "node:sqlite";
@@ -1,5 +1,5 @@
1
- import { G as Trigger } from "./intake-Di3PgVj2.js";
2
- import { t as SourceKind } from "./kind-DGutnJ2b.js";
1
+ import { G as Trigger } from "./intake-B1UsoHuw.js";
2
+ import { t as SourceKind } from "./kind-BYtP14oM.js";
3
3
  import { Context } from "@deepseek-ai/cordis";
4
4
  import { z } from "zod";
5
5
  //#region src/sources/dsh.d.ts
package/locale/en.json CHANGED
File without changes
package/locale/zh.json CHANGED
File without changes
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-foreman",
3
3
  "author": "niechao136",
4
4
  "license": "MIT",
5
- "version": "0.1.1",
5
+ "version": "0.3.1",
6
6
  "description": "dsh-foreman:主 loop 在多设备上派单的 DSH bundle:需求来自本机操作者的主会话而不是 GitHub/飞书,每个工作区各有一条主会话,主会话只负责路由,真正的活儿派给 Worker 在该工作区目录里执行。",
7
7
  "type": "module",
8
8
  "main": "lib/index.js",
@@ -62,7 +62,7 @@
62
62
  },
63
63
  "packageManager": "pnpm@11.7.0",
64
64
  "dependencies": {
65
- "dsh-foreman-node": "0.1.0",
65
+ "dsh-foreman-node": "0.2.0",
66
66
  "proper-lockfile": "4.1.2",
67
67
  "ws": "8.22.0",
68
68
  "zod": "4.4.3"
@@ -28,7 +28,6 @@ The host posts it as a reply to the triggering message; do not quote that messag
28
28
  Return rule_assessments with exactly one entry for every enabled rule: rule_id, matched and a factual reason.
29
29
  Include negative judgments as matched=false; a reason saying that a rule does not apply cannot be a positive match.
30
30
  Never include disabled, unknown or repeated rule IDs.
31
- Fill device only when the request itself says where the work has to happen — a named machine, an office-only system, a machine the person says they are at. Otherwise leave it out: the host picks the least busy online machine that can reach the project, and a guess here can send work to a machine that cannot do it.
32
31
  The host derives positive-only matches from those booleans.
33
32
  One source message produces at most one ticket.
34
33
  If matching rules conflict or the scope is ambiguous, ignore quietly.
package/prompts/cli.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/prompts/shared.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
package/prompts/work.md CHANGED
File without changes
package/prompts/worker.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes