pi-roundtable-web 0.23.1 → 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 (88) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +90 -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 +161 -0
  59. package/src/app/app.ts +28 -0
  60. package/src/app/rooms.ts +7 -1
  61. package/src/app/routes.ts +223 -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 +185 -24
  65. package/src/options.ts +182 -5
  66. package/src/push/mutes.ts +68 -0
  67. package/src/push/quiet-hours.ts +60 -0
  68. package/src/push/triggers.ts +40 -9
  69. package/src/web-plugin.ts +46 -2
  70. package/dist/chunk-character-creator-660d2t6p.js +0 -1
  71. package/dist/chunk-connectors-d19v8cjc.js +0 -1
  72. package/dist/chunk-conversations-4cmexmqq.js +0 -1
  73. package/dist/chunk-entry-8xpx35ef.js +0 -1
  74. package/dist/chunk-index-19g8jmr8.js +0 -2
  75. package/dist/chunk-index-362dfedj.js +0 -1
  76. package/dist/chunk-index-6g69tqt0.js +0 -47
  77. package/dist/chunk-index-89q2qdkj.css +0 -1
  78. package/dist/chunk-index-9tvf6es7.js +0 -41
  79. package/dist/chunk-index-p8h0e861.js +0 -2
  80. package/dist/chunk-index-xw5hhmfq.js +0 -2
  81. package/dist/chunk-notes-fefwds14.js +0 -1
  82. package/dist/chunk-overview-bxpwpmn6.js +0 -1
  83. package/dist/chunk-skills-pce3zgxy.js +0 -1
  84. package/dist/chunk-table-zmpbrfvz.js +0 -1
  85. package/dist/chunk-table2d-ac2gez71.js +0 -1
  86. package/dist/chunk-table3d-g258jemr.js +0 -4229
  87. package/dist/chunk-transcript-yhtjp3pd.js +0 -1
  88. package/dist/chunk-zh-tw-anqhqpaz.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: "首頁",
@@ -435,6 +555,8 @@ export const ZH_TW: Readonly<Record<string, string>> = {
435
555
  "Notifications were not allowed. Allow them in the browser's site settings.":
436
556
  "通知未獲允許。請在瀏覽器的網站設定中允許通知。",
437
557
  Notifications: "通知",
558
+ "Quiet hours, {start} to {end} ({zone}): no notification for an agent's report or a notice. A card waiting for you still notifies. The server sets the hours.":
559
+ "勿擾時段 {start} 至 {end}({zone}):這段時間內,夥伴的回報與通知不會推播;等你處理的卡片仍會推播。時段由伺服器設定。",
438
560
  Remove: "移除",
439
561
  "Signed in as {name}.": "目前登入身分:{name}。",
440
562
  "This browser cannot get push notifications. On iPhone and iPad, install the app from Share, Add to Home Screen, and open it from there.":
@@ -467,6 +589,12 @@ export const ZH_TW: Readonly<Record<string, string>> = {
467
589
 
468
590
  // 圓桌
469
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": "點一下顯示整段訊息",
470
598
 
471
599
  // 對話記錄
472
600
  "(empty)": "(空白)",
@@ -489,10 +617,43 @@ export const ZH_TW: Readonly<Record<string, string>> = {
489
617
  "The body is not JSON.": "內容不是 JSON。",
490
618
  "The body is not an object.": "內容不是物件。",
491
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 必須是路徑。",
492
652
  "Method not allowed.": "不允許使用這個方法。",
493
653
  "There is no such API.": "沒有這個 API。",
494
654
  "before and limit must be whole numbers.": "before 與 limit 必須是整數。",
495
655
  "Give yolo or longRunning.": "請提供 yolo 或 longRunning。",
656
+ "muted must be true or false.": "muted 必須是 true 或 false。",
496
657
  "This pane is not served.": "沒有提供這個頁面。",
497
658
  "The schedule id is a number.": "排程 ID 必須是數字。",
498
659
  "There is no such schedule.": "找不到這個排程。",
@@ -611,42 +772,42 @@ export const ZH_TW: Readonly<Record<string, string>> = {
611
772
  // 服裝
612
773
  "character.outfit.robe": "長袍",
613
774
  "character.outfit.scholar": "學者服",
614
- "character.outfit.armor": "盔甲",
775
+ "character.outfit.armor": "鎖子甲",
615
776
  "character.outfit.plate": "板甲",
616
- "character.outfit.apron": "圍裙",
617
- "character.outfit.chef": "廚師服",
777
+ "character.outfit.apron": "鐵匠皮圍裙",
778
+ "character.outfit.chef": "廚工束衣",
618
779
  "character.outfit.cloak": "斗篷",
619
780
  "character.outfit.tunic": "束腰外衣",
620
- "character.outfit.coat": "大衣",
621
- "character.outfit.overalls": "吊帶工作褲",
622
- "character.outfit.vest": "背心",
781
+ "character.outfit.coat": "緊身上衣",
782
+ "character.outfit.overalls": "工匠皮甲背心",
783
+ "character.outfit.vest": "絮縫短襖",
623
784
  "character.outfit.habit": "修士服",
624
785
  "character.outfit.surplice": "法衣",
625
786
  "character.outfit.motley": "小丑裝",
626
- "character.outfit.sailor": "水手服",
787
+ "character.outfit.sailor": "水手束衣",
627
788
  // 帽子
628
789
  "character.hat.none": "無帽子",
629
790
  "character.hat.wizard": "巫師帽",
630
791
  "character.hat.helm": "頭盔",
631
- "character.hat.mortar": "學士帽",
632
- "character.hat.goggcap": "護目鏡帽",
792
+ "character.hat.mortar": "學者四角帽",
793
+ "character.hat.goggcap": "皮護耳帽",
633
794
  "character.hat.hood": "兜帽",
634
- "character.hat.beret": "貝雷帽",
635
- "character.hat.goggles": "護目鏡",
795
+ "character.hat.beret": "夏普倫頭巾帽",
796
+ "character.hat.goggles": "尖頂軟帽",
636
797
  "character.hat.coif": "緊身頭巾",
637
- "character.hat.tallcap": "高帽",
638
- "character.hat.tricorn": "三角帽",
798
+ "character.hat.tallcap": "勃艮第氈帽",
799
+ "character.hat.tricorn": "朝聖者闊邊帽",
639
800
  "character.hat.turban": "纏頭巾",
640
801
  "character.hat.softcap": "軟帽",
641
- "character.hat.hardhat": "安全帽",
802
+ "character.hat.hardhat": "鐵製圓盔",
642
803
  "character.hat.wreath": "花冠",
643
- "character.hat.toque": "廚師帽",
804
+ "character.hat.toque": "廚工亞麻軟帽",
644
805
  "character.hat.kettle": "鐵壺盔",
645
- "character.hat.cavalier": "騎士帽",
806
+ "character.hat.cavalier": "羽飾帽",
646
807
  "character.hat.cowl": "風帽",
647
808
  "character.hat.headband": "髮帶",
648
- "character.hat.fcap": "平頂帽",
649
- "character.hat.sailor": "水手帽",
809
+ "character.hat.fcap": "獵人帽",
810
+ "character.hat.sailor": "草帽",
650
811
  "character.hat.jester": "小丑帽",
651
812
  "character.hat.winghelm": "翼盔",
652
813
  // 道具
@@ -656,14 +817,14 @@ export const ZH_TW: Readonly<Record<string, string>> = {
656
817
  "character.prop.hammer": "鎚子",
657
818
  "character.prop.bow": "弓",
658
819
  "character.prop.lute": "魯特琴",
659
- "character.prop.flask": "燒瓶",
820
+ "character.prop.flask": "煉金小瓶",
660
821
  "character.prop.quill": "羽毛筆",
661
822
  "character.prop.tome": "典籍",
662
823
  "character.prop.bell": "鈴鐺",
663
824
  "character.prop.keys": "鑰匙",
664
- "character.prop.map": "地圖",
665
- "character.prop.telescope": "望遠鏡",
666
- "character.prop.wrench": "扳手",
825
+ "character.prop.map": "海圖",
826
+ "character.prop.telescope": "星盤",
827
+ "character.prop.wrench": "圓規",
667
828
  "character.prop.spade": "鏟子",
668
829
  "character.prop.ladle": "長柄勺",
669
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,8 +6,14 @@ 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";
12
+ import {
13
+ DEFAULT_QUIET_HOURS,
14
+ minutesOfDay,
15
+ type QuietWindow,
16
+ } from "./push/quiet-hours.ts";
11
17
  import { pushFetchOf } from "./push/test-fetch.ts";
12
18
  import type { RequestVerifier } from "./verifier.ts";
13
19
 
@@ -118,13 +124,36 @@ export interface WebConsoleOptions {
118
124
  /** Hash routes by default; path routes retain links such as /console/skills. */
119
125
  routing?: "hash" | "path";
120
126
  /**
121
- * The panes to serve, in the order the page lists them; default `rooms`, `table`, `overview`,
122
- * `schedules`, `conversations`, `notes`. `rooms` is the owner app: the same agent team as on
123
- * 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.
124
133
  * An explicit list is kept as written, so a host that listed the panes before 0.23 adds
125
- * `rooms`, `table` and `schedules` by hand.
134
+ * `rooms`, `table` and `schedules` by hand, and one that listed them before 0.24 adds
135
+ * `workspace`.
126
136
  */
127
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
+ };
128
157
  /**
129
158
  * The key prefix of the app's rooms: `<surface>:agent.<name>` and `<surface>:group.<name>`.
130
159
  * A lower-case word that is not `discord`; default `app`. Rooms keep their conversations under
@@ -157,6 +186,13 @@ export interface WebConsoleOptions {
157
186
  * true. With `false` it says only that something is waiting and where.
158
187
  */
159
188
  preview?: boolean;
189
+ /**
190
+ * The hours when an agent's post and a notice are not pushed, as `HH:MM` times on the
191
+ * console's time zone (`timeZone` of the host); a window that ends before it starts crosses
192
+ * midnight. A card (one waiting, or the long-running cap) is pushed all the same. Default
193
+ * 23:00 to 08:00; `false` pushes at any hour. The two times must differ.
194
+ */
195
+ quietHours?: QuietWindow | false;
160
196
  /**
161
197
  * Push services the server may post to besides the browsers' own (Google, Mozilla, Apple,
162
198
  * Microsoft), as host names; a leading `*.` allows every subdomain. Their endpoints must
@@ -195,11 +231,21 @@ export interface ResolvedOptions {
195
231
  listener: string;
196
232
  routing: "hash" | "path";
197
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
+ };
198
242
  surface: string;
199
243
  cardGraceSeconds: number;
200
244
  uploadMaxBytes: number;
201
245
  push: {
202
246
  preview: boolean;
247
+ /** The quiet hours; undefined = none. */
248
+ quietHours: QuietWindow | undefined;
203
249
  endpointHosts: readonly string[];
204
250
  /** The VAPID contact; undefined = push is off. */
205
251
  subject: string | undefined;
@@ -261,6 +307,7 @@ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
261
307
  return [
262
308
  "rooms",
263
309
  "table",
310
+ "workspace",
264
311
  "overview",
265
312
  "schedules",
266
313
  "conversations",
@@ -277,9 +324,65 @@ function checkPanes(panes: readonly PaneName[] | undefined): PaneName[] {
277
324
  fail("the schedules pane needs the rooms pane, which serves its API");
278
325
  if (panes.includes("table") && !panes.includes("rooms"))
279
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");
280
329
  return [...panes];
281
330
  }
282
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
+
283
386
  function checkAuth(options: WebConsoleOptions): ResolvedAuth {
284
387
  const { auth, verifier } = options;
285
388
  if (auth !== undefined && verifier !== undefined)
@@ -367,6 +470,24 @@ function checkGrace(seconds: number | undefined): number {
367
470
  return seconds;
368
471
  }
369
472
 
473
+ function checkQuietHours(
474
+ value: QuietWindow | false | undefined,
475
+ ): QuietWindow | undefined {
476
+ if (value === undefined) return { ...DEFAULT_QUIET_HOURS };
477
+ if (value === false) return undefined;
478
+ const bad = () =>
479
+ fail(
480
+ 'push.quietHours must be false or {start, end} as two different HH:MM times, such as {start: "23:00", end: "08:00"}',
481
+ );
482
+ if (typeof value !== "object" || value === null) return bad();
483
+ const { start, end } = value as Partial<QuietWindow>;
484
+ if (typeof start !== "string" || typeof end !== "string") return bad();
485
+ const from = minutesOfDay(start);
486
+ const to = minutesOfDay(end);
487
+ if (from === undefined || to === undefined || from === to) return bad();
488
+ return { start, end };
489
+ }
490
+
370
491
  function checkPush(
371
492
  options: WebConsoleOptions,
372
493
  origin: string,
@@ -374,6 +495,7 @@ function checkPush(
374
495
  const push = options.push;
375
496
  if (push?.preview !== undefined && typeof push.preview !== "boolean")
376
497
  fail("push.preview must be true or false");
498
+ const quietHours = checkQuietHours(push?.quietHours);
377
499
  const hosts = push?.endpointHosts ?? [];
378
500
  if (!Array.isArray(hosts))
379
501
  fail("push.endpointHosts must be a list of host names");
@@ -396,6 +518,7 @@ function checkPush(
396
518
  );
397
519
  return {
398
520
  preview: push?.preview ?? true,
521
+ quietHours,
399
522
  endpointHosts: [...hosts],
400
523
  subject: given ?? (origin.startsWith("https:") ? origin : undefined),
401
524
  fetch: pushFetchOf(options),
@@ -456,6 +579,8 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
456
579
  listener: options.listener ?? "public",
457
580
  routing: options.routing ?? "hash",
458
581
  panes,
582
+ panesGiven: options.panes !== undefined,
583
+ workspace: checkWorkspace(options, options.dataDir ?? "data"),
459
584
  surface: checkSurface(options.surface),
460
585
  cardGraceSeconds: checkGrace(options.cardGraceSeconds),
461
586
  uploadMaxBytes: checkUploadMax(options.uploadMaxBytes),
@@ -470,3 +595,55 @@ export function resolveOptions(options: WebConsoleOptions): ResolvedOptions {
470
595
  presentation: options.presentation,
471
596
  };
472
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
+ }