agent-loft 0.2.35 → 0.2.37

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/README.md CHANGED
@@ -66,7 +66,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
66
66
  - **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
67
67
  - **技能(skills)**:设置 → 技能。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。每人管自己的,不做分享和技能广场(以前从别人那装过的照常能用,可以卸载)。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在「设置 → 技能 → 本机技能」里能看到。
68
68
  - **远程控制**:侧栏「定时任务」下面的「远程控制」,直接操作这台机器的**桌面**和**终端**,上面一键切换;切回对话时连接留在后台(侧栏入口上亮个小绿点),再进来接着用。这台机器没有图形桌面(服务器、NAS)时只有终端。
69
- - 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,可以缩放到窗口或按原始大小看、浏览器全屏、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 通用 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
69
+ - 桌面:走 VNC 协议,网页里用 noVNC 显示,鼠标、键盘、剪贴板都能用,进来时不自动连、点「连接桌面」才连,可以缩放到窗口或按原始大小看、全屏(手机上也能,iPhone 上是铺满页面)、「只看不控制」、发送 Ctrl+Alt+Del 之类的组合键。手机上有两种手势(右上角切换,「?」看说明):**触控板模式**(默认)屏幕上有个指针,单指滑动移动、轻点单击、双指轻点右键、长按后拖动是按住左键拖、双指滑动滚动;**触摸模式**点哪就点哪。两种模式都能双指捏合在本地放大画面(放大后小按钮也点得准,右下角「还原」),画面下面一排 Esc / Tab / Ctrl / Alt / Win / 方向键,Ctrl、Alt、Win 点一下会粘住,带给下一个按键或点击;键盘按钮弹出软键盘打字,中文输入法也能用。Linux 上 agent-loft 在有人连时用 x11vnc 共享当前的桌面(Xorg 会话;只听 127.0.0.1、随机端口、每次随机密码,需要 `sudo apt install x11vnc`),没人连着 30 分钟后停掉;macOS 上在 设置 → 通用 → 远程控制 里改用「已有的 VNC 服务」,接系统自带的屏幕共享。
70
70
  - 终端:新终端开在当前对话的工作目录里,可以开多个标签;`Ctrl+Shift+C` / `Ctrl+Shift+V` 复制粘贴。`` Ctrl+` `` 在对话下面拉出终端面板(高度可拖动),面板和远程控制里的是同一批终端。关掉页面、手机切到后台、断线都不结束 shell,重新打开时接着用、补上错过的输出,别的设备上也能接上;没有页面连着超过 30 分钟(设置里可改)才结束,点标签上的 × 立即结束。依赖可选的原生模块 `node-pty`:Linux 上安装时要现场编译(需要 `make`、`g++`、`python3`),没装好时终端用不了,其他功能照常。手机上单指上下滑翻看历史(在 less、vim 里是翻页),长按选中文字、接着拖动扩大选区,再点「复制」;终端下面多一排 Esc / Tab / Ctrl / Alt / 方向键 / Home / End / PgUp / PgDn / 粘贴。iOS 上软键盘弹出来时这排键会垫到键盘上面。
71
71
  - 管理员能用;成员要管理员在 设置 → 用户 里单独「开通远程控制」。终端、桌面在 设置 → 通用 → 远程控制 里各有总开关。
72
72
  - **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.35",
3
+ "version": "0.2.37",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -53,6 +53,17 @@
53
53
  }
54
54
  }
55
55
 
56
+ /* 顶栏下沿的渐变、模糊是给滚动的聊天内容用的:远程控制页下面是工具栏,会被它盖住一截 */
57
+ .main.remote-view .topbar {
58
+ padding-bottom: 8px;
59
+ background: var(--bg);
60
+ }
61
+
62
+ .main.remote-view .topbar::before,
63
+ .main.remote-view .topbar::after {
64
+ display: none;
65
+ }
66
+
56
67
  .remote-head {
57
68
  display: flex;
58
69
  align-items: center;
@@ -313,11 +324,120 @@
313
324
  opacity: 0.4;
314
325
  }
315
326
 
316
- /* 浏览器全屏时只剩画面 */
317
- .remote-body:fullscreen .desk-status {
327
+ /* 全屏时只剩画面 */
328
+ .remote-body.full .desk-status {
318
329
  display: none;
319
330
  }
320
331
 
332
+ /* 不支持元素全屏(iPhone):铺满整个页面,盖住顶栏、侧栏,弹窗和提示还在上面。
333
+ 左右不留安全区:画面本来就居中、两边是黑边,刘海落在黑边里 */
334
+ .remote-body.desk-full {
335
+ position: fixed;
336
+ inset: 0;
337
+ z-index: 35;
338
+ background: #111;
339
+ padding: env(safe-area-inset-top) 0 var(--kb-inset, 0px);
340
+ }
341
+
342
+ /* 全屏时下面那排键默认收起,左上角的按钮拉出来;拉出来时是深色、矮一点的一条 */
343
+ .remote-body.full .desk-keys {
344
+ padding: 4px max(8px, env(safe-area-inset-left)) calc(4px + env(safe-area-inset-bottom)) max(8px, env(safe-area-inset-right));
345
+ border-top: none;
346
+ background: #111;
347
+ }
348
+
349
+ .remote-body.full:not(.keys-open) .desk-keys {
350
+ display: none;
351
+ }
352
+
353
+ /* 有软键盘垫着时不用再让出底部安全区 */
354
+ .remote-body.desk-full .desk-keys {
355
+ padding-bottom: calc(4px + max(0px, env(safe-area-inset-bottom) - var(--kb-inset, 0px)));
356
+ }
357
+
358
+ .remote-body.full .desk-keys .term-key {
359
+ height: 30px;
360
+ border-color: #333;
361
+ background: #262626;
362
+ color: #eee;
363
+ }
364
+
365
+ .remote-body.full .desk-keys .term-key.on {
366
+ background: var(--accent);
367
+ border-color: var(--accent);
368
+ color: var(--accent-fg);
369
+ }
370
+
371
+ /* 没连着时画面中间的「连接桌面」 */
372
+ .desk-idle {
373
+ position: absolute;
374
+ inset: 0;
375
+ z-index: 3;
376
+ display: flex;
377
+ flex-direction: column;
378
+ align-items: center;
379
+ justify-content: center;
380
+ gap: 12px;
381
+ background: var(--bg);
382
+ }
383
+
384
+ .desk-idle[hidden],
385
+ .desk-fbtn[hidden] {
386
+ display: none;
387
+ }
388
+
389
+ .desk-idle > span svg {
390
+ width: 44px;
391
+ height: 44px;
392
+ color: var(--fg-soft);
393
+ }
394
+
395
+ .desk-idle .btn {
396
+ display: inline-flex;
397
+ align-items: center;
398
+ gap: 6px;
399
+ }
400
+
401
+ .desk-idle .btn svg {
402
+ width: 16px;
403
+ height: 16px;
404
+ }
405
+
406
+ /* 全屏时左上角「退出全屏」「按键」 */
407
+ .desk-fullbar {
408
+ position: absolute;
409
+ left: max(10px, env(safe-area-inset-left));
410
+ top: 10px;
411
+ z-index: 3;
412
+ display: flex;
413
+ gap: 8px;
414
+ }
415
+
416
+ .desk-fullbar[hidden] {
417
+ display: none;
418
+ }
419
+
420
+ .desk-fbtn.on {
421
+ background: var(--accent);
422
+ color: var(--accent-fg);
423
+ }
424
+
425
+ .desk-fbtn {
426
+ display: grid;
427
+ place-items: center;
428
+ width: 34px;
429
+ height: 34px;
430
+ border: none;
431
+ border-radius: 50%;
432
+ background: rgba(0, 0, 0, 0.55);
433
+ color: #fff;
434
+ }
435
+
436
+ .desk-fbtn svg {
437
+ width: 18px;
438
+ height: 18px;
439
+ }
440
+
321
441
  .desk-clip {
322
442
  width: 100%;
323
443
  margin-top: 10px;
package/web/lib/dom.js CHANGED
@@ -53,7 +53,9 @@ export const ICON = {
53
53
  command: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/></svg>',
54
54
  eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>',
55
55
  expand: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M21 8V5a2 2 0 0 0-2-2h-3M3 16v3a2 2 0 0 0 2 2h3M16 21h3a2 2 0 0 0 2-2v-3"/></svg>',
56
- power: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/></svg>',
56
+ plug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8Z"/></svg>',
57
+ unplug: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m19 5 3-3"/><path d="m2 22 3-3"/><path d="M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z"/><path d="M7.5 13.5 10 11"/><path d="M10.5 16.5 13 14"/><path d="m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z"/></svg>',
58
+ shrink: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v3a2 2 0 0 1-2 2H3M21 8h-3a2 2 0 0 1-2-2V3M3 16h3a2 2 0 0 1 2 2v3M16 21v-3a2 2 0 0 1 2-2h3"/></svg>',
57
59
  pointer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3l14 7-6 2-2 6Z"/></svg>',
58
60
  hand: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11V5a1.5 1.5 0 0 1 3 0v5"/><path d="M12 10V4a1.5 1.5 0 0 1 3 0v6"/><path d="M15 10V6a1.5 1.5 0 0 1 3 0v8a7 7 0 0 1-7 7h-.5a6 6 0 0 1-4.6-2.2L3.4 15a1.6 1.6 0 0 1 2.4-2.1L9 15.5V11"/></svg>',
59
61
  help: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9.2a2.6 2.6 0 0 1 5 .9c0 1.7-2.5 2.3-2.5 3.9M12 17h.01"/></svg>',
@@ -9,6 +9,7 @@ import { createTouch } from "./desktop-touch.js";
9
9
 
10
10
  // ---------- 远程桌面(noVNC) ----------
11
11
  // 画面、鼠标、键盘、剪贴板都走 VNC 协议:页面 ↔ /ws/desktop ↔ 服务端的 VNC(Linux 上是 agent-loft 按需起的 x11vnc)。
12
+ // 进来时不自动连,点了「连接」才连(Linux 上会起 x11vnc)。
12
13
  // 离开远程控制页时连接不断,画面在后台照常更新;断线了自己重连,点了「断开」才不连。
13
14
  // 手机上:触摸事件由 desktop-touch.js 接管(触控板 / 触摸两种模式、双指本地放大),下面多一排按键。
14
15
 
@@ -41,7 +42,7 @@ function setLive(on) {
41
42
  renderSidebar();
42
43
  }
43
44
 
44
- /** 远程控制页切到「桌面」:第一次进来就连上 */
45
+ /** 远程控制页切到「桌面」:没连着时中间一个「连接」按钮,点了才连 */
45
46
  export function showDesktop(pane, tools) {
46
47
  if (!D.pane) {
47
48
  D.pane = pane;
@@ -49,7 +50,19 @@ export function showDesktop(pane, tools) {
49
50
  D.screen = h("div", { class: "desk-screen" });
50
51
  D.zoomBadge = h("button", { class: "desk-zoom", hidden: true, onclick: () => D.touch?.resetZoom() });
51
52
  D.hint = h("div", { class: "desk-hint", hidden: true });
52
- D.viewport = h("div", { class: "desk-viewport" }, D.screen, D.zoomBadge, D.hint);
53
+ D.idle = h(
54
+ "div",
55
+ { class: "desk-idle", hidden: true },
56
+ icon("monitor"),
57
+ h("div", { class: "muted" }, "远程桌面未连接"),
58
+ h("button", { class: "btn primary", onclick: reconnect }, icon("plug"), "连接桌面"),
59
+ );
60
+ D.keysBtn = isTouch
61
+ ? h("button", { class: "desk-fbtn", title: "按键", "aria-label": "按键", onclick: () => toggleFullKeys() }, icon("keyboard"))
62
+ : null;
63
+ D.exitFull = h("button", { class: "desk-fbtn", title: "退出全屏", "aria-label": "退出全屏", onclick: () => setFull(false) }, icon("shrink"));
64
+ D.fullBar = h("div", { class: "desk-fullbar", hidden: true }, D.exitFull, D.keysBtn);
65
+ D.viewport = h("div", { class: "desk-viewport" }, D.screen, D.zoomBadge, D.hint, D.idle, D.fullBar);
53
66
  D.keyInput = isTouch ? mobileKeyInput() : null;
54
67
  D.keyRow = isTouch ? keyRow() : null;
55
68
  fill(pane, D.status, D.viewport, D.keyRow, D.keyInput);
@@ -70,10 +83,6 @@ export function showDesktop(pane, tools) {
70
83
  D.tools = tools;
71
84
  renderTools();
72
85
  renderStatus();
73
- if (!D.rfb && D.state !== "connecting") {
74
- D.want = true;
75
- void connect();
76
- }
77
86
  }
78
87
 
79
88
  async function connect() {
@@ -99,6 +108,8 @@ async function connect() {
99
108
  rfb.resizeSession = false;
100
109
  rfb.viewOnly = D.viewOnly;
101
110
  rfb.showDotCursor = true;
111
+ // 画面没铺满时的边:和外框一个颜色,别再多出一圈灰
112
+ rfb.background = "#111";
102
113
  rfb.qualityLevel = 6;
103
114
  rfb.compressionLevel = 2;
104
115
  rfb.addEventListener("connect", () => {
@@ -204,13 +215,15 @@ function renderStatus() {
204
215
  ? "正在连接桌面…"
205
216
  : D.state === "error"
206
217
  ? D.message
207
- : "已断开";
218
+ : null;
208
219
  D.status.hidden = !text;
209
220
  fill(
210
221
  D.status,
211
222
  text ? h("span", { class: D.state === "error" ? "error-text" : "muted" }, text) : null,
212
- D.state === "idle" || (D.state === "error" && !D.want) ? h("button", { class: "btn small", onclick: reconnect }, "连接") : null,
223
+ D.state === "error" && !D.want ? h("button", { class: "btn small", onclick: reconnect }, "连接") : null,
213
224
  );
225
+ // 没连着(还没点过连接、点了断开):中间的「连接桌面」
226
+ D.idle.hidden = D.state !== "idle";
214
227
  D.screen.classList.toggle("dim", D.state !== "connected");
215
228
  D.touch?.refresh();
216
229
  }
@@ -252,13 +265,52 @@ function renderTools() {
252
265
  tool(D.viewOnly ? "只看(点一下恢复控制)" : "只看不控制", "eye", toggleViewOnly, { on: D.viewOnly }),
253
266
  // 手机上靠双指放大,不切原始大小
254
267
  isTouch ? null : tool(D.fit ? "原始大小" : "缩放到窗口", D.fit ? "maximize" : "restore", toggleFit),
255
- document.fullscreenEnabled ? tool("全屏", "expand", () => D.pane.requestFullscreen?.()) : null,
256
- on || D.state === "connecting"
257
- ? tool("断开", "power", disconnect)
258
- : tool("连接", "power", reconnect),
268
+ tool("全屏", "expand", () => setFull(true)),
269
+ on || D.state === "connecting" ? tool("断开连接", "unplug", disconnect) : tool("连接", "plug", reconnect),
259
270
  );
260
271
  }
261
272
 
273
+ // 全屏:浏览器支持就用真全屏;iPhone 的 Safari 不支持元素全屏,改成铺满整个页面(盖住顶栏、侧栏)
274
+ const fsEnabled = () => document.fullscreenEnabled || document.webkitFullscreenEnabled;
275
+ const fsElement = () => document.fullscreenElement ?? document.webkitFullscreenElement;
276
+
277
+ function setFull(on) {
278
+ if (on) {
279
+ if (fsEnabled()) {
280
+ const req = D.pane.requestFullscreen ?? D.pane.webkitRequestFullscreen;
281
+ // 被浏览器拒了(比如不是用户点的):退回铺满页面
282
+ Promise.resolve(req?.call(D.pane)).catch(() => D.pane.classList.add("desk-full"));
283
+ } else D.pane.classList.add("desk-full");
284
+ } else {
285
+ D.pane.classList.remove("desk-full");
286
+ if (fsElement() === D.pane) (document.exitFullscreen ?? document.webkitExitFullscreen)?.call(document);
287
+ }
288
+ syncFull();
289
+ }
290
+
291
+ /**
292
+ * 全屏时画面左上角「退出全屏」(手机上没有 Esc;真全屏在电脑上按 Esc 就行,不显示)。
293
+ * 手机上全屏时下面那排键先收起来,左上角的「按键」拉出来
294
+ */
295
+ function syncFull() {
296
+ if (!D.pane) return;
297
+ const pseudo = D.pane.classList.contains("desk-full");
298
+ const full = pseudo || fsElement() === D.pane;
299
+ if (full !== D.pane.classList.contains("full")) {
300
+ D.pane.classList.toggle("full", full);
301
+ toggleFullKeys(false);
302
+ }
303
+ D.fullBar.hidden = !(pseudo || (isTouch && full));
304
+ }
305
+
306
+ function toggleFullKeys(open = !D.pane.classList.contains("keys-open")) {
307
+ D.pane.classList.toggle("keys-open", open);
308
+ D.keysBtn?.classList.toggle("on", open);
309
+ if (!open && document.activeElement === D.keyInput) D.keyInput.blur();
310
+ }
311
+ document.addEventListener("fullscreenchange", syncFull);
312
+ document.addEventListener("webkitfullscreenchange", syncFull);
313
+
262
314
  function toggleViewOnly() {
263
315
  D.viewOnly = !D.viewOnly;
264
316
  if (D.rfb) D.rfb.viewOnly = D.viewOnly;