pi-roundtable-web 0.23.2 → 0.24.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +82 -16
  3. package/dist/chunk-action-menu-7v5fce2v.js +1 -0
  4. package/dist/chunk-agent-2rw4ydw2.js +1 -0
  5. package/dist/chunk-agent-sheet-2q24dkkf.js +3 -0
  6. package/dist/chunk-character-creator-8q6ympxj.js +1 -0
  7. package/dist/chunk-codeeditor-fgmq298t.js +2 -0
  8. package/dist/chunk-connectors-mr53r7fh.js +1 -0
  9. package/dist/chunk-conversations-4vy2z6w9.js +1 -0
  10. package/dist/chunk-entry-jg1eem82.js +1 -0
  11. package/dist/chunk-group-scene-na95b1te.js +1 -0
  12. package/dist/chunk-index-11rjg8a8.js +1 -0
  13. package/dist/chunk-index-1y50d6sj.js +2 -0
  14. package/dist/chunk-index-5y2r0eaj.js +47 -0
  15. package/dist/chunk-index-6t68gbex.js +2 -0
  16. package/dist/chunk-index-7fphxxzd.js +14 -0
  17. package/dist/chunk-index-80f1zkp6.css +1 -0
  18. package/dist/chunk-index-87h45frg.js +2 -0
  19. package/dist/chunk-index-bnwmssyn.js +2 -0
  20. package/dist/chunk-index-ckgbszwt.js +51 -0
  21. package/dist/chunk-index-fmjjekhd.js +2 -0
  22. package/dist/chunk-index-mpm90318.js +2 -0
  23. package/dist/chunk-index-t1m65yn6.js +13 -0
  24. package/dist/chunk-index-t711t17m.js +2 -0
  25. package/dist/chunk-index-txv7qvyy.js +2 -0
  26. package/dist/chunk-index-wr3dg24d.js +2 -0
  27. package/dist/chunk-index-yx683d5n.js +2 -0
  28. package/dist/chunk-lang-css-fkdg3x8x.js +1 -0
  29. package/dist/chunk-lang-dockerfile-ey0nvhhm.js +1 -0
  30. package/dist/chunk-lang-go-403qsw9f.js +7 -0
  31. package/dist/chunk-lang-html-v8h9g977.js +1 -0
  32. package/dist/chunk-lang-javascript-ykb2vhaa.js +1 -0
  33. package/dist/chunk-lang-json-2rze3rpy.js +1 -0
  34. package/dist/chunk-lang-markdown-6w2htnfc.js +6 -0
  35. package/dist/chunk-lang-python-9jk5a49y.js +3 -0
  36. package/dist/chunk-lang-rust-24fggcqx.js +1 -0
  37. package/dist/chunk-lang-shell-cgq6q0kj.js +1 -0
  38. package/dist/chunk-lang-sql-gjywb9ke.js +2 -0
  39. package/dist/chunk-lang-toml-xh45nm68.js +1 -0
  40. package/dist/chunk-lang-typescript-qh6rebyb.js +1 -0
  41. package/dist/chunk-lang-xml-1mh879j9.js +1 -0
  42. package/dist/chunk-lang-yaml-jac98srt.js +1 -0
  43. package/dist/chunk-menu-items-zjpyj51b.js +2 -0
  44. package/dist/chunk-notes-sjba7kw3.js +1 -0
  45. package/dist/chunk-overview-26r77kb6.js +1 -0
  46. package/dist/chunk-room-confirms-851184ep.js +1 -0
  47. package/dist/chunk-room-menu-8y9c026y.js +1 -0
  48. package/dist/chunk-skills-56s4hdbn.js +1 -0
  49. package/dist/chunk-table-qykpvp32.js +1 -0
  50. package/dist/chunk-table2d-k60ressj.js +1 -0
  51. package/dist/chunk-table3d-bv2ajk39.js +4220 -0
  52. package/dist/chunk-transcript-vekz1mh6.js +1 -0
  53. package/dist/chunk-workspace-hevefd0d.js +1 -0
  54. package/dist/chunk-zh-tw-5r2wjre0.js +1 -0
  55. package/dist/index.html +2 -1
  56. package/dist/sw.js +1 -1
  57. package/package.json +21 -3
  58. package/src/api-types.ts +152 -0
  59. package/src/app/app.ts +11 -0
  60. package/src/app/rooms.ts +7 -1
  61. package/src/app/routes.ts +218 -1
  62. package/src/app/workspace-files.ts +1485 -0
  63. package/src/console-api.ts +5 -0
  64. package/src/i18n/zh-tw.ts +183 -24
  65. package/src/options.ts +148 -5
  66. package/src/push/mutes.ts +68 -0
  67. package/src/push/triggers.ts +30 -11
  68. package/src/web-plugin.ts +42 -2
  69. package/dist/chunk-character-creator-dwk6y4be.js +0 -1
  70. package/dist/chunk-connectors-pk0fh4y6.js +0 -1
  71. package/dist/chunk-conversations-sfsgmsdm.js +0 -1
  72. package/dist/chunk-entry-x0j9g57j.js +0 -1
  73. package/dist/chunk-index-19g8jmr8.js +0 -2
  74. package/dist/chunk-index-362dfedj.js +0 -1
  75. package/dist/chunk-index-6g69tqt0.js +0 -47
  76. package/dist/chunk-index-89q2qdkj.css +0 -1
  77. package/dist/chunk-index-9k42hnam.js +0 -41
  78. package/dist/chunk-index-k0z1ykph.js +0 -2
  79. package/dist/chunk-index-p8h0e861.js +0 -2
  80. package/dist/chunk-notes-v60aswry.js +0 -1
  81. package/dist/chunk-overview-c1c0ccda.js +0 -1
  82. package/dist/chunk-skills-r8rwbc5y.js +0 -1
  83. package/dist/chunk-table-pmr7zrgm.js +0 -1
  84. package/dist/chunk-table2d-ac2gez71.js +0 -1
  85. package/dist/chunk-table3d-g258jemr.js +0 -4229
  86. package/dist/chunk-transcript-wjqk37f5.js +0 -1
  87. package/dist/chunk-zh-tw-mtvm7sn7.js +0 -1
@@ -81,6 +81,8 @@ export interface ConsolePorts {
81
81
  presentation?: ConsolePresentation;
82
82
  mountPath?: string;
83
83
  look?: "modern" | "camelot";
84
+ /** The host's six skin tones, which the page draws every character with. */
85
+ characterSkins?: readonly string[];
84
86
  /** Present when the owner app is served: its rooms' key prefix, for the page. */
85
87
  app?: { surface: string; protocol: string };
86
88
  }
@@ -245,6 +247,9 @@ export class ConsoleApi {
245
247
  ...(features?.cleanup ? { cleanup: true } : {}),
246
248
  ...(connectorAdminUrl ? { connectorAdminUrl } : {}),
247
249
  look: this.#ports.look ?? "modern",
250
+ ...(this.#ports.characterSkins
251
+ ? { characterSkins: [...this.#ports.characterSkins] }
252
+ : {}),
248
253
  ...(this.#ports.mountPath ? { mountPath: this.#ports.mountPath } : {}),
249
254
  ...(this.#ports.app ? { app: this.#ports.app } : {}),
250
255
  };
package/src/i18n/zh-tw.ts CHANGED
@@ -25,6 +25,95 @@ export const ZH_TW: Readonly<Record<string, string>> = {
25
25
  Settings: "設定",
26
26
  More: "更多",
27
27
 
28
+ // 檔案
29
+ Files: "檔案",
30
+ "this place is read-only": "此區唯讀",
31
+ "inside .git": ".git 內的檔案",
32
+ "in the trash": "垃圾桶內的檔案",
33
+ "a link": "連結",
34
+ "binary file": "二進位檔",
35
+ "too large": "檔案太大",
36
+ "not UTF-8": "不是 UTF-8",
37
+ "Agents' workspace": "夥伴工作區",
38
+ Scratch: "暫存區",
39
+ Path: "路徑",
40
+ "This folder is empty": "這個資料夾是空的",
41
+ "Show or hide {name}": "展開或收合 {name}",
42
+ "Outside the workspace": "連到工作區外",
43
+ "Only the first 2,000 items are listed": "只列出前 2,000 項",
44
+ "No files yet": "還沒有檔案",
45
+ "Not a regular file": "不是一般檔案",
46
+ "Actions for {name}": "{name} 的操作",
47
+ Saved: "已存檔",
48
+ Find: "尋找",
49
+ Replace: "取代",
50
+ next: "下一個",
51
+ previous: "上一個",
52
+ all: "全部",
53
+ "match case": "區分大小寫",
54
+ regexp: "正規表示式",
55
+ "by word": "整個單字",
56
+ replace: "取代",
57
+ "replace all": "全部取代",
58
+ close: "關閉",
59
+ "Read only: {reason}": "唯讀:{reason}",
60
+ "File menu": "檔案選單",
61
+ "This file is gone": "這個檔案已經不見了",
62
+ "A partner is changing this file": "夥伴正在改這個檔案",
63
+ "Reload (discard my edits)": "重新載入(放棄我的修改)",
64
+ "This file is too large to show here.": "這個檔案太大,無法在這裡顯示。",
65
+ "This file is not text, so it cannot be shown here.":
66
+ "這個檔案不是文字檔,無法在這裡顯示。",
67
+ "This browser cannot show this file.": "這個瀏覽器無法顯示這個檔案。",
68
+ "Fit to window": "符合視窗大小",
69
+ "Show at full size": "以原始大小顯示",
70
+ Open: "開啟",
71
+ Editing: "編輯中",
72
+ Undo: "復原",
73
+ Redo: "重做",
74
+ Tab: "Tab",
75
+ "Do not wrap lines": "不要自動換行",
76
+ "Wrap lines": "自動換行",
77
+ "This file changed while you were editing": "你編輯時,這個檔案被改了",
78
+ "Someone deleted it. Your text has not been saved.":
79
+ "有人把它刪除了。你的內容還沒有存檔。",
80
+ "Your text has not been saved. What should happen to it?":
81
+ "你的內容還沒有存檔。要怎麼處理?",
82
+ "Copy my text": "複製我的內容",
83
+ "Save mine as a new file": "存成新檔案",
84
+ "Save mine anyway": "仍用我的版本覆蓋",
85
+ "Replace the partner's changes?": "要蓋掉夥伴的修改嗎?",
86
+ "The file has been changed since you opened it. Saving yours writes over those changes.":
87
+ "你開啟後,這個檔案又被改過。存下你的版本會蓋掉那些修改。",
88
+ "Unsaved changes, leave?": "還沒存檔,要離開嗎?",
89
+ "Your edits to {name} have not been saved.": "你對 {name} 的修改還沒有存檔。",
90
+ Rename: "重新命名",
91
+ "New folder": "新增資料夾",
92
+ "New file": "新增檔案",
93
+ "Use a name without slashes.": "名稱不能含有斜線。",
94
+ Renamed: "已重新命名",
95
+ Created: "已建立",
96
+ Create: "建立",
97
+ Trash: "垃圾桶",
98
+ "The trash is empty": "垃圾桶是空的",
99
+ Restored: "已還原",
100
+ "The folder it was in is gone.": "它原本所在的資料夾已經不見了。",
101
+ Restore: "還原",
102
+ "Delete {name} for good": "永久刪除 {name}",
103
+ "Delete for good?": "要永久刪除嗎?",
104
+ "Delete for good": "永久刪除",
105
+ "“{name}” will be removed and cannot be brought back.":
106
+ "「{name}」會被永久移除,無法復原。",
107
+ "Read only": "唯讀",
108
+ Back: "返回",
109
+ Place: "位置",
110
+ Folders: "資料夾",
111
+ "Move to the trash?": "要移到垃圾桶嗎?",
112
+ "Moved to the trash": "已移到垃圾桶",
113
+ "“{name}” goes to the trash, where you can bring it back.":
114
+ "「{name}」會移到垃圾桶,之後可以還原。",
115
+ "Resize the folder list": "調整資料夾列表的寬度",
116
+
28
117
  // 錯誤邊界
29
118
  Reload: "重新載入",
30
119
  "Something went wrong": "發生了一些問題",
@@ -103,6 +192,7 @@ export const ZH_TW: Readonly<Record<string, string>> = {
103
192
  Accessory: "配件",
104
193
  Idle: "閒置",
105
194
  Thinking: "思考中",
195
+ "Thinking level": "思考強度",
106
196
  Working: "工作中",
107
197
  "Waiting for you": "等你回應",
108
198
  Done: "完成",
@@ -226,14 +316,12 @@ export const ZH_TW: Readonly<Record<string, string>> = {
226
316
  Yesterday: "昨天",
227
317
 
228
318
  // 夥伴
229
- "Back to the room": "回到聊天室",
230
319
  "Class, colors and parts. The picture here, in the rooms and in Discord all follow it.":
231
320
  "職業、配色與部件。這裡、聊天室與 Discord 的圖像都會跟著改變。",
232
321
  "Follow the assistant (automatic)": "跟隨助理(自動)",
233
322
  "Follow the assistant": "跟隨助理",
234
323
  Look: "造型",
235
324
  Name: "名稱",
236
- Profile: "個人檔案",
237
325
  Prompt: "提示詞",
238
326
  "Runs on {model}, thinking {thinking}": "使用 {model},思考程度 {thinking}",
239
327
  "Saved.": "已儲存。",
@@ -377,10 +465,42 @@ export const ZH_TW: Readonly<Record<string, string>> = {
377
465
  "Turn on yolo mode": "開啟 Yolo 模式",
378
466
  "Working…": "處理中…",
379
467
 
468
+ // 長按與右鍵選單
469
+ "Edit agent": "編輯夥伴",
470
+ "Change look": "換外觀",
471
+ "Notifications are off": "通知已關閉",
472
+ "Notifications are on for {name}": "已開啟{name}的通知",
473
+ "Notifications are off for {name}": "已關閉{name}的通知",
474
+ "Push this room's posts to every owner's devices. A card that waits for you is always pushed.":
475
+ "把這個聊天室的發言推送到每位擁有者的裝置。等你處理的卡片一律會推送。",
476
+ "Full screen": "全螢幕",
477
+ "Review changes": "檢視變更",
478
+ "Back to editing": "繼續編輯",
479
+ "Lines: {added} added, {removed} removed":
480
+ "新增 {added} 行、移除 {removed} 行",
481
+ Added: "新增",
482
+ Removed: "移除",
483
+ Off: "關閉",
484
+ Minimal: "最低",
485
+ Low: "低",
486
+ Medium: "中",
487
+ High: "高",
488
+ "Very high": "極高",
489
+ "Copy as Markdown": "複製為 Markdown",
490
+ "Quote reply": "引用回覆",
491
+ "Select text": "選取文字",
492
+ Share: "分享",
493
+ "Agent profile": "夥伴資料",
494
+ "Copy details": "複製細節",
495
+ Download: "下載",
496
+ "Could not copy.": "無法複製。",
497
+ "Could not share.": "無法分享。",
498
+
380
499
  // 聊天室列表
381
500
  Agents: "夥伴",
382
501
  "Dispatch threads": "派工討論串",
383
502
  "Find a room": "尋找聊天室",
503
+ "Switch rooms": "切換聊天室",
384
504
  Group: "群組",
385
505
  Groups: "群組",
386
506
  Home: "首頁",
@@ -469,6 +589,12 @@ export const ZH_TW: Readonly<Record<string, string>> = {
469
589
 
470
590
  // 圓桌
471
591
  "Reset view": "重設視角",
592
+ Log: "記錄",
593
+ "Hide the message box": "收起對話框",
594
+ "Show the message box": "展開對話框",
595
+ "Back to the whole table": "回到整張圓桌",
596
+ You: "你",
597
+ "Tap to show the whole message": "點一下顯示整段訊息",
472
598
 
473
599
  // 對話記錄
474
600
  "(empty)": "(空白)",
@@ -491,10 +617,43 @@ export const ZH_TW: Readonly<Record<string, string>> = {
491
617
  "The body is not JSON.": "內容不是 JSON。",
492
618
  "The body is not an object.": "內容不是物件。",
493
619
  "Only an owner may do this.": "只有擁有者可以這麼做。",
620
+ "That path is not allowed.": "不允許這個路徑。",
621
+ "There is no such place.": "沒有這個位置。",
622
+ "There is no such file or folder.": "沒有這個檔案或資料夾。",
623
+ "That is not a folder.": "這不是資料夾。",
624
+ "That is a folder, not a file.": "這是資料夾,不是檔案。",
625
+ "That is the top folder, which cannot be changed.":
626
+ "這是最上層的資料夾,不能更動。",
627
+ "That points outside the workspace.": "這連到工作區外面。",
628
+ "That is not a regular file.": "這不是一般檔案。",
629
+ "That is a link; open the file it points to.":
630
+ "這是連結;請開啟它指向的檔案。",
631
+ "This place is read-only.": "這個位置是唯讀的。",
632
+ "Files inside .git are read-only.": ".git 內的檔案是唯讀的。",
633
+ "The trash cannot be changed this way.": "垃圾桶不能這樣更動。",
634
+ "This file is not text, so it cannot be edited.":
635
+ "這個檔案不是文字檔,無法編輯。",
636
+ "This file is not UTF-8 text, so it cannot be edited.":
637
+ "這個檔案不是 UTF-8 文字,無法編輯。",
638
+ "This file is too large.": "這個檔案太大了。",
639
+ "The text is too large to save (1 MiB at most).":
640
+ "文字太大,無法存檔(最多 1 MiB)。",
641
+ "A folder cannot be moved into itself.": "資料夾不能移進它自己裡面。",
642
+ "That cannot be moved to another disk.": "這不能移到另一個磁碟。",
643
+ "The server is not allowed to change that.": "伺服器沒有權限更動這個項目。",
644
+ "This file changed while you were editing.": "你編輯時,這個檔案被改了。",
645
+ "This file is gone.": "這個檔案已經不見了。",
646
+ "Something with that name is already there.": "已經有同名的項目了。",
647
+ "text must be text.": "text 必須是文字。",
648
+ "baseHash must be the hash the file was read with.":
649
+ "baseHash 必須是讀取檔案時取得的雜湊值。",
650
+ "kind must be file or dir.": "kind 必須是 file 或 dir。",
651
+ "from and to must be paths.": "from 與 to 必須是路徑。",
494
652
  "Method not allowed.": "不允許使用這個方法。",
495
653
  "There is no such API.": "沒有這個 API。",
496
654
  "before and limit must be whole numbers.": "before 與 limit 必須是整數。",
497
655
  "Give yolo or longRunning.": "請提供 yolo 或 longRunning。",
656
+ "muted must be true or false.": "muted 必須是 true 或 false。",
498
657
  "This pane is not served.": "沒有提供這個頁面。",
499
658
  "The schedule id is a number.": "排程 ID 必須是數字。",
500
659
  "There is no such schedule.": "找不到這個排程。",
@@ -613,42 +772,42 @@ export const ZH_TW: Readonly<Record<string, string>> = {
613
772
  // 服裝
614
773
  "character.outfit.robe": "長袍",
615
774
  "character.outfit.scholar": "學者服",
616
- "character.outfit.armor": "盔甲",
775
+ "character.outfit.armor": "鎖子甲",
617
776
  "character.outfit.plate": "板甲",
618
- "character.outfit.apron": "圍裙",
619
- "character.outfit.chef": "廚師服",
777
+ "character.outfit.apron": "鐵匠皮圍裙",
778
+ "character.outfit.chef": "廚工束衣",
620
779
  "character.outfit.cloak": "斗篷",
621
780
  "character.outfit.tunic": "束腰外衣",
622
- "character.outfit.coat": "大衣",
623
- "character.outfit.overalls": "吊帶工作褲",
624
- "character.outfit.vest": "背心",
781
+ "character.outfit.coat": "緊身上衣",
782
+ "character.outfit.overalls": "工匠皮甲背心",
783
+ "character.outfit.vest": "絮縫短襖",
625
784
  "character.outfit.habit": "修士服",
626
785
  "character.outfit.surplice": "法衣",
627
786
  "character.outfit.motley": "小丑裝",
628
- "character.outfit.sailor": "水手服",
787
+ "character.outfit.sailor": "水手束衣",
629
788
  // 帽子
630
789
  "character.hat.none": "無帽子",
631
790
  "character.hat.wizard": "巫師帽",
632
791
  "character.hat.helm": "頭盔",
633
- "character.hat.mortar": "學士帽",
634
- "character.hat.goggcap": "護目鏡帽",
792
+ "character.hat.mortar": "學者四角帽",
793
+ "character.hat.goggcap": "皮護耳帽",
635
794
  "character.hat.hood": "兜帽",
636
- "character.hat.beret": "貝雷帽",
637
- "character.hat.goggles": "護目鏡",
795
+ "character.hat.beret": "夏普倫頭巾帽",
796
+ "character.hat.goggles": "尖頂軟帽",
638
797
  "character.hat.coif": "緊身頭巾",
639
- "character.hat.tallcap": "高帽",
640
- "character.hat.tricorn": "三角帽",
798
+ "character.hat.tallcap": "勃艮第氈帽",
799
+ "character.hat.tricorn": "朝聖者闊邊帽",
641
800
  "character.hat.turban": "纏頭巾",
642
801
  "character.hat.softcap": "軟帽",
643
- "character.hat.hardhat": "安全帽",
802
+ "character.hat.hardhat": "鐵製圓盔",
644
803
  "character.hat.wreath": "花冠",
645
- "character.hat.toque": "廚師帽",
804
+ "character.hat.toque": "廚工亞麻軟帽",
646
805
  "character.hat.kettle": "鐵壺盔",
647
- "character.hat.cavalier": "騎士帽",
806
+ "character.hat.cavalier": "羽飾帽",
648
807
  "character.hat.cowl": "風帽",
649
808
  "character.hat.headband": "髮帶",
650
- "character.hat.fcap": "平頂帽",
651
- "character.hat.sailor": "水手帽",
809
+ "character.hat.fcap": "獵人帽",
810
+ "character.hat.sailor": "草帽",
652
811
  "character.hat.jester": "小丑帽",
653
812
  "character.hat.winghelm": "翼盔",
654
813
  // 道具
@@ -658,14 +817,14 @@ export const ZH_TW: Readonly<Record<string, string>> = {
658
817
  "character.prop.hammer": "鎚子",
659
818
  "character.prop.bow": "弓",
660
819
  "character.prop.lute": "魯特琴",
661
- "character.prop.flask": "燒瓶",
820
+ "character.prop.flask": "煉金小瓶",
662
821
  "character.prop.quill": "羽毛筆",
663
822
  "character.prop.tome": "典籍",
664
823
  "character.prop.bell": "鈴鐺",
665
824
  "character.prop.keys": "鑰匙",
666
- "character.prop.map": "地圖",
667
- "character.prop.telescope": "望遠鏡",
668
- "character.prop.wrench": "扳手",
825
+ "character.prop.map": "海圖",
826
+ "character.prop.telescope": "星盤",
827
+ "character.prop.wrench": "圓規",
669
828
  "character.prop.spade": "鏟子",
670
829
  "character.prop.ladle": "長柄勺",
671
830
  "character.prop.lantern": "提燈",
package/src/options.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { join } from "node:path";
1
+ import { join, resolve } from "node:path";
2
2
  import type { ChannelKey, RoundtablePlugin } from "pi-roundtable";
3
3
  import { PluginError } from "pi-roundtable";
4
4
  import { PANES, type PaneName } from "./api-types.ts";
@@ -6,6 +6,7 @@ import {
6
6
  DEFAULT_UPLOAD_MAX_BYTES,
7
7
  UPLOAD_FRAMING_BYTES,
8
8
  } from "./app/uploads.ts";
9
+ import { checkRoots, type WorkspaceRoot } from "./app/workspace-files.ts";
9
10
  import type { ConsoleFeatures, ConsolePresentation } from "./features.ts";
10
11
  import { isHostPattern } from "./push/endpoint.ts";
11
12
  import {
@@ -123,13 +124,36 @@ export interface WebConsoleOptions {
123
124
  /** Hash routes by default; path routes retain links such as /console/skills. */
124
125
  routing?: "hash" | "path";
125
126
  /**
126
- * The panes to serve, in the order the page lists them; default `rooms`, `table`, `overview`,
127
- * `schedules`, `conversations`, `notes`. `rooms` is the owner app: the same agent team as on
128
- * Discord, in rooms of its own; `table` seats its agents at the round table and needs `rooms`.
127
+ * The panes to serve, in the order the page lists them; default `rooms`, `table`, `workspace`,
128
+ * `overview`, `schedules`, `conversations`, `notes`. `rooms` is the owner app: the same agent
129
+ * team as on Discord, in rooms of its own; `table` seats its agents at the round table and
130
+ * needs `rooms`; `workspace` browses and edits the agents' files and needs `rooms` and a
131
+ * directory to show (the agents' own, or `workspace.roots`). The default list leaves
132
+ * `workspace` out when there is no directory; an explicit list that names it must have one.
129
133
  * An explicit list is kept as written, so a host that listed the panes before 0.23 adds
130
- * `rooms`, `table` and `schedules` by hand.
134
+ * `rooms`, `table` and `schedules` by hand, and one that listed them before 0.24 adds
135
+ * `workspace`.
131
136
  */
132
137
  panes?: readonly PaneName[];
138
+ /**
139
+ * What the `workspace` pane shows. The agents' working directory (`agents`) and their scratch
140
+ * directory (`scratch`, when the host has one) are shown without being named, from
141
+ * `AGENTS.workspace`; `roots` adds more, or `agents: false` / `scratch: false` leaves one out.
142
+ * A root must not be, or contain, the host's `dataDir`, which holds the sessions and keys.
143
+ */
144
+ workspace?: {
145
+ /** More directories to show: an `id` (a lower-case word), a `label`, an absolute `dir`, and whether the owner may change files in it (default false). */
146
+ roots?: readonly {
147
+ id: string;
148
+ label: string;
149
+ dir: string;
150
+ writable?: boolean;
151
+ }[];
152
+ /** Leave the agents' working directory out of the pane; default shown. */
153
+ agents?: boolean;
154
+ /** Leave the agents' scratch directory out of the pane; default shown when the host has one. */
155
+ scratch?: boolean;
156
+ };
133
157
  /**
134
158
  * The key prefix of the app's rooms: `<surface>:agent.<name>` and `<surface>:group.<name>`.
135
159
  * A lower-case word that is not `discord`; default `app`. Rooms keep their conversations under
@@ -207,6 +231,14 @@ export interface ResolvedOptions {
207
231
  listener: string;
208
232
  routing: "hash" | "path";
209
233
  panes: PaneName[];
234
+ /** Whether `panes` was written out, which makes a missing directory for the workspace pane an error. */
235
+ panesGiven: boolean;
236
+ /** The `workspace` option checked and filled in. */
237
+ workspace: {
238
+ roots: readonly WorkspaceRoot[];
239
+ agents: boolean;
240
+ scratch: boolean;
241
+ };
210
242
  surface: string;
211
243
  cardGraceSeconds: number;
212
244
  uploadMaxBytes: number;
@@ -275,6 +307,7 @@ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
275
307
  return [
276
308
  "rooms",
277
309
  "table",
310
+ "workspace",
278
311
  "overview",
279
312
  "schedules",
280
313
  "conversations",
@@ -291,9 +324,65 @@ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
291
324
  fail("the schedules pane needs the rooms pane, which serves its API");
292
325
  if (panes.includes("table") && !panes.includes("rooms"))
293
326
  fail("the table pane needs the rooms pane, whose agents it seats");
327
+ if (panes.includes("workspace") && !panes.includes("rooms"))
328
+ fail("the workspace pane needs the rooms pane, which serves its API");
294
329
  return [...panes];
295
330
  }
296
331
 
332
+ /**
333
+ * What no place of the files pane may be or contain: the host's data directory, and Pi's agent
334
+ * directory in its default place beside the sessions (`auth.json` lives there).
335
+ */
336
+ export function protectedDirs(dataDir: string): string[] {
337
+ return [resolve(dataDir), resolve(dataDir, "pi")];
338
+ }
339
+
340
+ function checkWorkspace(
341
+ options: WebConsoleOptions,
342
+ dataDir: string,
343
+ ): ResolvedOptions["workspace"] {
344
+ const given = options.workspace;
345
+ if (given === undefined) return { roots: [], agents: true, scratch: true };
346
+ if (typeof given !== "object" || given === null)
347
+ return fail("workspace must be an object such as { roots: [...] }");
348
+ for (const key of ["agents", "scratch"] as const)
349
+ if (given[key] !== undefined && typeof given[key] !== "boolean")
350
+ fail(`workspace.${key} must be true or false`);
351
+ const list = given.roots ?? [];
352
+ if (!Array.isArray(list)) fail("workspace.roots must be a list");
353
+ const roots: WorkspaceRoot[] = list.map((root, index) => {
354
+ if (
355
+ typeof root !== "object" ||
356
+ root === null ||
357
+ typeof root.id !== "string" ||
358
+ typeof root.label !== "string" ||
359
+ !root.label.trim() ||
360
+ typeof root.dir !== "string" ||
361
+ (root.writable !== undefined && typeof root.writable !== "boolean")
362
+ )
363
+ return fail(
364
+ `workspace.roots[${index}] must be { id, label, dir, writable? } with text for the first three`,
365
+ );
366
+ return {
367
+ id: root.id,
368
+ label: root.label.trim(),
369
+ dir: root.dir,
370
+ writable: root.writable === true,
371
+ };
372
+ });
373
+ for (const reserved of ["agents", "scratch"])
374
+ if (roots.some((root) => root.id === reserved))
375
+ fail(
376
+ `workspace.roots may not use the id ${reserved}, which names the agents' own directory`,
377
+ );
378
+ checkRoots(roots, protectedDirs(dataDir));
379
+ return {
380
+ roots,
381
+ agents: given.agents !== false,
382
+ scratch: given.scratch !== false,
383
+ };
384
+ }
385
+
297
386
  function checkAuth(options: WebConsoleOptions): ResolvedAuth {
298
387
  const { auth, verifier } = options;
299
388
  if (auth !== undefined && verifier !== undefined)
@@ -490,6 +579,8 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
490
579
  listener: options.listener ?? "public",
491
580
  routing: options.routing ?? "hash",
492
581
  panes,
582
+ panesGiven: options.panes !== undefined,
583
+ workspace: checkWorkspace(options, options.dataDir ?? "data"),
493
584
  surface: checkSurface(options.surface),
494
585
  cardGraceSeconds: checkGrace(options.cardGraceSeconds),
495
586
  uploadMaxBytes: checkUploadMax(options.uploadMaxBytes),
@@ -504,3 +595,55 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
504
595
  presentation: options.presentation,
505
596
  };
506
597
  }
598
+
599
+ /** The directories the workspace pane shows: the agents' own, then the host's `workspace.roots`. */
600
+ export function workspaceRootsOf(
601
+ settings: ResolvedOptions,
602
+ agents: { workDir: string; scratchDir?: string } | undefined,
603
+ ): WorkspaceRoot[] {
604
+ const roots: WorkspaceRoot[] = [];
605
+ if (agents && settings.workspace.agents)
606
+ roots.push({
607
+ id: "agents",
608
+ label: "Agents' workspace",
609
+ dir: agents.workDir,
610
+ writable: true,
611
+ });
612
+ if (agents?.scratchDir && settings.workspace.scratch)
613
+ roots.push({
614
+ id: "scratch",
615
+ label: "Scratch",
616
+ dir: agents.scratchDir,
617
+ writable: true,
618
+ });
619
+ return [...roots, ...settings.workspace.roots];
620
+ }
621
+
622
+ /**
623
+ * Roots the default pane list may show. A directory of the agents that holds the host's data would
624
+ * stop the host from starting after an upgrade that only added a pane, so such a root is left out,
625
+ * with the reason; the roots a host names itself are kept, and `checkRoots` fails on them.
626
+ */
627
+ export function showableRoots(
628
+ roots: readonly WorkspaceRoot[],
629
+ guarded: readonly string[],
630
+ ): { roots: WorkspaceRoot[]; left: { id: string; reason: string }[] } {
631
+ const kept: WorkspaceRoot[] = [];
632
+ const left: { id: string; reason: string }[] = [];
633
+ for (const root of roots) {
634
+ if (root.id !== "agents" && root.id !== "scratch") {
635
+ kept.push(root);
636
+ continue;
637
+ }
638
+ try {
639
+ checkRoots([root], guarded);
640
+ kept.push(root);
641
+ } catch (error) {
642
+ left.push({
643
+ id: root.id,
644
+ reason: error instanceof Error ? error.message : String(error),
645
+ });
646
+ }
647
+ }
648
+ return { roots: kept, left };
649
+ }
@@ -0,0 +1,68 @@
1
+ import type { SQL } from "bun";
2
+ import type { Migration } from "pi-roundtable";
3
+
4
+ /**
5
+ * The rooms whose owners asked for no push, one row per muted room in `web_push_muted_rooms`.
6
+ * A mute belongs to the room, not to a device or a person: every owner's devices follow it.
7
+ */
8
+ export class PushMutes {
9
+ readonly #sql: SQL;
10
+
11
+ constructor(sql: SQL) {
12
+ this.#sql = sql;
13
+ }
14
+
15
+ static readonly migration: Migration = {
16
+ name: "web-push-muted-rooms",
17
+ up: async (sql) => {
18
+ await sql`
19
+ CREATE TABLE IF NOT EXISTS web_push_muted_rooms (
20
+ room text PRIMARY KEY,
21
+ muted_by text NOT NULL,
22
+ muted_at timestamptz NOT NULL DEFAULT now()
23
+ )`;
24
+ },
25
+ };
26
+
27
+ /** Whether the room is muted. */
28
+ async has(room: string): Promise<boolean> {
29
+ const rows: unknown[] = await this
30
+ .#sql`SELECT 1 FROM web_push_muted_rooms WHERE room = ${room}`;
31
+ return rows.length > 0;
32
+ }
33
+
34
+ /** Every muted room. */
35
+ async all(): Promise<Set<string>> {
36
+ const rows: { room: string }[] = await this
37
+ .#sql`SELECT room FROM web_push_muted_rooms`;
38
+ return new Set(rows.map((row) => row.room));
39
+ }
40
+
41
+ /** Mutes or unmutes a room; doing either twice leaves it as it is. */
42
+ async set(room: string, muted: boolean, by: string): Promise<void> {
43
+ if (muted)
44
+ await this.#sql`
45
+ INSERT INTO web_push_muted_rooms (room, muted_by) VALUES (${room}, ${by})
46
+ ON CONFLICT (room) DO NOTHING`;
47
+ else await this.#sql`DELETE FROM web_push_muted_rooms WHERE room = ${room}`;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Whether a room's push is held back. A dispatch thread has no mute of its own: it follows the room
53
+ * it was opened under, so `threadPrefix` (the keys of every thread start with it) maps a thread's key
54
+ * to its parent.
55
+ */
56
+ export function roomMuted(
57
+ mutes: { has(room: string): Promise<boolean> },
58
+ threads: { get(id: string): Promise<{ parent: string } | undefined> },
59
+ threadPrefix: string,
60
+ ): (room: string) => Promise<boolean> {
61
+ return async (room) => {
62
+ if (!room.startsWith(threadPrefix)) return mutes.has(room);
63
+ const thread = await threads.get(
64
+ room.slice(threadPrefix.length - "thread.".length),
65
+ );
66
+ return mutes.has(thread?.parent ?? room);
67
+ };
68
+ }
@@ -28,6 +28,11 @@ export interface PushServiceOptions {
28
28
  * not pushed then, while a card is. Default never.
29
29
  */
30
30
  quiet?: (at: number) => boolean;
31
+ /**
32
+ * Whether the owners muted a room: an agent's post in it is not pushed, while a
33
+ * card is. Default never.
34
+ */
35
+ muted?: (room: string) => Promise<boolean>;
31
36
  }
32
37
 
33
38
  /** What a trigger says; the service shapes it into a notification. */
@@ -54,8 +59,8 @@ export function previewOf(text: string): string {
54
59
  /**
55
60
  * Decides when an owner's devices are pushed and what is said. Each trigger pushes only while no
56
61
  * page of an owner is in view, and only to owners: a device whose person is no owner any more is
57
- * skipped. In the quiet hours an agent's post and a notice wait for the owner to look; a card,
58
- * which holds up work, is still pushed. Delivery never throws into the code that raised the trigger.
62
+ * skipped. In the quiet hours an agent's post and a notice wait for the owner to look; in a room the
63
+ * owners muted only the post does. A card, which holds up work, is still pushed. Delivery never throws into the code that raised the trigger.
59
64
  */
60
65
  export class PushService {
61
66
  readonly #options: PushServiceOptions;
@@ -77,14 +82,14 @@ export class PushService {
77
82
  }
78
83
 
79
84
  /** An agent posted in a room, such as a finished job's report or an answer nobody was watching for. */
80
- reply(room: string, author: string, text: string): Promise<void> {
85
+ async reply(room: string, author: string, text: string): Promise<void> {
81
86
  const now = this.#now();
82
- if (this.#options.quiet?.(now)) return Promise.resolve();
87
+ if (this.#options.quiet?.(now)) return;
88
+ if (await this.#muted(room)) return;
83
89
  const last = this.#repliedAt.get(room);
84
- if (last !== undefined && now - last < REPLY_QUIET_MS)
85
- return Promise.resolve();
90
+ if (last !== undefined && now - last < REPLY_QUIET_MS) return;
86
91
  this.#repliedAt.set(room, now);
87
- return this.#push({
92
+ await this.#push({
88
93
  kind: "reply",
89
94
  title: this.#options.preview ? author : "New reply",
90
95
  text,
@@ -93,10 +98,14 @@ export class PushService {
93
98
  });
94
99
  }
95
100
 
96
- /** A notice for one person, from the `notify` tool. */
97
- notice(principalId: string, text: string, room?: string): Promise<void> {
98
- if (this.#options.quiet?.(this.#now())) return Promise.resolve();
99
- return this.#push({
101
+ /** A notice for one person, from the `notify` tool. A mute does not hold it back: a notice is addressed to the person, and it always names the home room. */
102
+ async notice(
103
+ principalId: string,
104
+ text: string,
105
+ room?: string,
106
+ ): Promise<void> {
107
+ if (this.#options.quiet?.(this.#now())) return;
108
+ await this.#push({
100
109
  kind: "notice",
101
110
  title: "Notice",
102
111
  text,
@@ -106,6 +115,16 @@ export class PushService {
106
115
  });
107
116
  }
108
117
 
118
+ /** Whether the room is muted; a mute that cannot be read leaves the owner's pushes on, with a warning. */
119
+ async #muted(room: string): Promise<boolean> {
120
+ try {
121
+ return (await this.#options.muted?.(room)) ?? false;
122
+ } catch (error) {
123
+ this.#options.logger.warn({ err: error, room }, "a mute was not read");
124
+ return false;
125
+ }
126
+ }
127
+
109
128
  #now(): number {
110
129
  return (this.#options.now ?? Date.now)();
111
130
  }