@timqi/pier 0.1.14 → 0.2.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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -38,115 +38,46 @@
38
38
  which style.css pays for with the safe-area inset. -->
39
39
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
40
40
  <title>Pier</title>
41
- <script type="module" crossorigin src="/app/assets/index-BQo-haPN.js"></script>
42
- <link rel="stylesheet" crossorigin href="/app/assets/index-DiHj0w1i.css">
41
+ <script type="module" crossorigin src="/app/assets/index-BpFzCwlo.js"></script>
42
+ <link rel="modulepreload" crossorigin href="/app/assets/highlight-5Bl8zZ8q.js">
43
+ <link rel="modulepreload" crossorigin href="/app/assets/hljs-BRz6a8Dq.js">
44
+ <link rel="modulepreload" crossorigin href="/app/assets/report-zjE8Bt8S.js">
45
+ <link rel="stylesheet" crossorigin href="/app/assets/index-wAGxFFPj.css">
43
46
  </head>
44
47
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
45
48
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
46
49
  exceed the visible viewport (mobile chrome, short windows) — that gap is
47
- what lets the whole app slide up and take the sidebar with it. -->
48
- <body class="grid h-dvh grid-cols-1 overflow-hidden text-[16px] leading-[1.45] text-neutral-800 antialiased md:grid-cols-[14.5rem_minmax(0,1fr)]">
49
- <!-- Below md the aside leaves the grid and slides in as a drawer (style.css);
50
- the scrim is its dismiss target and never paints on desktop. -->
51
- <div id="drawer-scrim" class="fixed inset-0 z-30 hidden bg-black/25 md:hidden"></div>
52
- <!-- Desktop only: one sidebar toggle. Fixed in the title band — style.css
53
- moves it between the rail's top-right (open) and main's top-left
54
- (closed), flipping the chevron, so expand and collapse are the same
55
- control in the same band. -->
56
- <button type="button" id="rail-toggle" aria-label="Toggle sidebar"
57
- class="fixed top-1.5 z-40 hidden h-7 w-7 cursor-pointer items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 md:flex">
58
- <span data-icon="ChevronLeft" class="h-3.5 w-3.5"></span>
59
- </button>
60
- <aside id="sidebar" class="flex flex-col overflow-hidden border-r border-neutral-200 bg-neutral-50 font-sans">
61
- <header class="flex h-12 flex-none items-center gap-2 px-3">
62
- <!-- The same mark as the tab and the home-screen icon, so an installed
63
- Pier looks like the thing you installed. -->
64
- <img src="/app/icon.svg" alt="" width="20" height="20" class="h-7 w-7 flex-none rounded-lg border border-neutral-200/70 bg-white p-1 shadow-xs" />
65
- <h1 class="text-[17px] font-semibold tracking-tight">Pier</h1>
50
+ what lets the whole app slide up and take the bar with it. -->
51
+ <body class="h-dvh overflow-hidden text-[16px] leading-[1.45] text-neutral-800 antialiased">
52
+ <main class="h-full min-w-0 overflow-hidden">
53
+ <!-- Settings moves both into its own head (settings.ts); they live here
54
+ until then because version.ts and theme.ts wire them at boot. -->
55
+ <section id="settings-view" class="hidden min-h-0 flex-1 flex-col">
66
56
  <!-- A link, not a label: AGPL-3.0 section 13 asks a network service to
67
- point its users at the source it is running. Beside the title
68
- because a version is something you look up, not something you find
69
- by scrolling to the bottom. -->
57
+ point its users at the source it is running. -->
70
58
  <a id="version" href="https://github.com/timqi/pier" target="_blank" rel="noreferrer"
71
59
  title="Pier is free software (AGPL-3.0) — the source of what you are using"
72
- class="font-mono text-[11px] text-neutral-400 hover:text-neutral-700 hover:underline"></a>
60
+ class="flex-none font-mono text-[11px] text-neutral-400 hover:text-neutral-700 hover:underline"></a>
73
61
  <!-- Cycles system → light → dark; the glyph is what is on screen, the
74
- title says which of the three states put it there. Beside the
75
- version rather than at the header's right edge, which is where the
76
- fixed rail toggle lands. -->
62
+ title says which of the three states put it there. -->
77
63
  <button type="button" id="theme-toggle" aria-label="Theme"
78
64
  class="icon-btn text-[13px]"></button>
79
- </header>
80
- <div class="flex flex-none items-center gap-1.5 px-3 pb-1">
81
- <button type="button" id="new-session" aria-label="New session"
82
- class="btn btn-primary flex min-w-0 flex-1 items-center justify-center gap-2 whitespace-nowrap px-3">
83
- <span data-icon="Plus" aria-hidden="true" class="h-3.5 w-3.5"></span> New session
84
- </button>
85
- <button type="button" id="open-palette" aria-label="Search sessions, messages and Console"
86
- class="btn flex h-8 w-8 flex-none items-center justify-center bg-neutral-100 p-0 text-neutral-600 hover:bg-neutral-200 active:bg-neutral-300">
87
- <span data-icon="Search" class="h-5 w-5 flex-none"></span>
88
- <span class="sr-only">Search</span>
89
- </button>
90
- </div>
91
- <details id="console-section" open class="flex-none border-b border-neutral-200/70">
92
- <summary class="flex cursor-pointer select-none items-center gap-1 px-3 py-1 text-xs font-semibold leading-5 text-neutral-500 hover:bg-neutral-100">
93
- <span data-icon="ChevronRight" class="chev h-3 w-3"></span>Console
94
- </summary>
95
- <ul class="pb-1">
96
- <!-- One row for the tasks area: Tasks, Runs and Activity are tabs of
97
- the Automation page (views.ts), not three sidebar entries. -->
98
- <li><button type="button" id="open-tasks"
99
- class="flex w-full cursor-pointer items-center gap-2 pr-3 text-left hover:bg-neutral-100">
100
- <span data-icon="ListTodo" class="h-3.5 w-3.5 flex-none text-neutral-400"></span>Automation</button></li>
101
- <li><button type="button" id="open-boards"
102
- class="flex w-full cursor-pointer items-center gap-2 pr-3 text-left hover:bg-neutral-100">
103
- <span data-icon="LayoutDashboard" class="h-3.5 w-3.5 flex-none text-neutral-400"></span>Boards</button></li>
104
- <li><button type="button" id="open-settings"
105
- class="flex w-full cursor-pointer items-center gap-2 pr-3 text-left hover:bg-neutral-100">
106
- <span data-icon="Settings" class="h-3.5 w-3.5 flex-none text-neutral-400"></span>Settings</button></li>
107
- </ul>
108
- </details>
109
- <div class="flex-none px-3 pb-1 pt-2 text-xs font-semibold leading-5 text-neutral-500">Sessions</div>
110
- <div id="session-list" class="min-h-0 flex-1 overflow-y-auto"></div>
111
- </aside>
112
-
113
- <main class="min-w-0 overflow-hidden">
114
- <!-- Mobile top bar: replaces the chat header below md (which hides), so a
115
- Console view keeps the same way back to the drawer as a chat does. -->
116
- <header id="mobile-bar" class="flex flex-none items-center gap-1 px-1.5 md:hidden">
117
- <button type="button" id="drawer-toggle" aria-label="Open sidebar"
118
- class="icon-btn relative h-11 w-11 text-neutral-500">
119
- <span data-icon="Menu" class="h-4.5 w-4.5"></span>
120
- </button>
121
- <span id="mobile-title" class="min-w-0 flex-1 truncate text-[16px] font-medium"></span>
122
- <!-- Both bar controls are the shared round icon button at a thumb's size
123
- (44px), with the chat header's own 14px ⋯ glyph — not a row of
124
- oversized dots. -->
125
- <button type="button" id="mobile-menu" aria-label="Session actions"
126
- class="icon-btn hidden h-11 w-11"><span data-icon="Ellipsis" class="h-3.5 w-3.5"></span></button>
127
- </header>
128
- <!-- Automation hosts three views under one head — title, tabs and the
129
- page's primary action on one strip, drawn by views.ts; its active pill
130
- tracks the route (#/tasks, #/runs, #/activity). -->
131
- <section id="automation-view" class="hidden min-h-0 flex-1 flex-col">
132
- <header id="automation-tabs" class="pagehead"></header>
133
- <section id="tasks-view" class="hidden min-h-0 flex-1 flex-col"></section>
134
- <section id="runs-view" class="hidden min-h-0 flex-1 flex-col"></section>
135
- <section id="activity-view" class="hidden min-h-0 flex-1 flex-col"></section>
136
65
  </section>
137
- <section id="boards-view" class="hidden min-h-0 flex-1 flex-col"></section>
138
- <section id="settings-view" class="hidden min-h-0 flex-1 flex-col"></section>
139
- <section id="files-view" class="hidden min-h-0 flex-1 flex-col"></section>
140
- <header id="chat-header" class="flex h-10 flex-none items-center gap-2 border-b border-neutral-200 px-4 max-md:hidden">
141
- <span id="chat-title" class="truncate font-medium"></span>
142
- <!-- Right cluster: meta chips sit flush against the ⋯ button. Two
143
- ml-autos would split the free space, so one wrapper owns it. -->
144
- <div class="ml-auto flex flex-none items-center gap-1.5">
145
- <!-- Inline session meta: model · reasoning · current context size. -->
146
- <div id="session-meta" class="hidden flex-none items-center gap-1 text-[11px]"></div>
147
- <button type="button" id="chat-menu" title="Session actions" aria-label="Session actions"
148
- class="icon-btn"><span data-icon="Ellipsis" class="h-3.5 w-3.5"></span></button>
149
- </div>
66
+ <!-- The one bar, at every width: the way back, the title (Session info),
67
+ the phase tag, then the status chip (the status panel, drawer.ts),
68
+ the meta chips and ⋯ (session-header.ts). Flat children, so below md the
69
+ urgent meta row can wrap to a line of its own (style.css). -->
70
+ <header id="bar" class="flex flex-none items-center gap-1.5 px-1.5 md:px-3">
71
+ <button type="button" id="bar-back" aria-label="Back to the conversation" title="Back to the conversation"
72
+ class="icon-btn hidden h-11 w-11 gap-0.5 text-neutral-500"></button>
73
+ <button type="button" id="chat-title" class="min-w-0 truncate text-left font-medium"></button>
74
+ <span id="chat-phase" class="mr-auto flex flex-none items-center"></span>
75
+ <button type="button" id="status-chip" title="Status"
76
+ class="hidden max-w-[45vw] flex-none cursor-pointer truncate rounded-full bg-neutral-100 px-2 py-0.5 text-[12px] font-medium text-neutral-600 hover:bg-neutral-200"></button>
77
+ <!-- Inline session meta: model · reasoning · current context size. -->
78
+ <div id="session-meta" class="hidden flex-none items-center gap-1 text-[11px]"></div>
79
+ <button type="button" id="chat-menu" title="Session actions" aria-label="Session actions"
80
+ class="icon-btn"><span data-icon="Ellipsis" class="h-3.5 w-3.5"></span></button>
150
81
  </header>
151
82
  <!-- The transcript's own type scale, inherited by rows, chips and markdown. -->
152
83
  <!-- data-gallery: the lightbox pages within the container it was opened
@@ -157,7 +88,7 @@
157
88
  <div id="queue-panel" class="hidden flex-col border-t border-neutral-200 px-4 pb-3 pt-2">
158
89
  <div class="flex h-7 items-center gap-2">
159
90
  <span id="queue-label" class="text-[11px] font-semibold uppercase tracking-wide text-amber-700">Queued</span>
160
- <div class="ml-auto flex items-center gap-0.5 text-amber-700">
91
+ <div id="queue-actions" class="ml-auto flex items-center gap-0.5 text-amber-700">
161
92
  <button type="button" id="queue-steer" title="Send the whole queue now into the running turn" aria-label="Send queue now"
162
93
  class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-amber-100 active:bg-amber-200">
163
94
  <span data-icon="ArrowUp" class="h-3.5 w-3.5"></span>
@@ -179,6 +110,11 @@
179
110
  <form id="composer" class="flex-none px-4 pb-4 pt-2">
180
111
  <div class="glass rounded-[1.75rem] border border-neutral-200 px-3 py-2.5 transition-shadow focus-within:border-indigo-400">
181
112
  <div id="image-strip" data-gallery class="hidden flex-wrap gap-2 px-1 pb-1.5 pt-1"></div>
113
+ <!-- The chat commands and skills, offered while the draft is a `/` prefix
114
+ of one (composer.ts): the input keeps the caret, ↑/↓ walk, Enter or Tab
115
+ picks. Rows are subgrids of its two columns, so the lines align;
116
+ eight rows (36px, 44px on touch: style.css) and the padding, then it scrolls. -->
117
+ <ul id="command-menu" role="listbox" aria-label="Commands and skills" class="hidden max-h-[18.5rem] grid-cols-[auto_minmax(0,1fr)] overflow-y-auto overscroll-contain pb-1.5 pt-0.5"></ul>
182
118
  <!-- items-end keeps the icons on the bottom row while the textarea grows upward -->
183
119
  <div class="flex items-end gap-1.5">
184
120
  <button type="button" id="attach" title="Attach file (or paste / drop)"
@@ -261,16 +197,11 @@
261
197
  <button type="button" id="image-close" aria-label="Close" class="lightbox-btn right-2 top-[calc(0.5rem+env(safe-area-inset-top))]"><span data-icon="X" class="h-6 w-6"></span></button>
262
198
  </dialog>
263
199
 
264
- <!-- Text preview of an agent attachment — the Files view's code pane fills
265
- #file-text; download stays one click away. -->
266
- <dialog id="file-dialog" class="m-auto w-[45rem] max-w-[92vw] rounded-3xl border border-neutral-200 p-0 shadow-xl backdrop:bg-black/40">
267
- <div class="flex items-center gap-2 border-b border-neutral-200 px-3 py-2">
268
- <span id="file-name" class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap font-mono text-[12.5px] text-neutral-700"></span>
269
- <a id="file-download" download class="btn flex-none text-[12px] no-underline">Download</a>
270
- <button type="button" id="file-close" class="btn flex-none text-[12px]">Close</button>
271
- </div>
272
- <div id="file-text" class="max-h-[70vh] overflow-auto"></div>
273
- </dialog>
200
+ <!-- The Files dialog (ui/explorer.ts fills it): a tree beside a viewer, over
201
+ the chat or Settings. Nearly the viewport at every width, and the whole
202
+ screen below md, where it pays the safe-area insets `main` pays. -->
203
+ <dialog id="files-dialog" aria-label="Files"
204
+ class="m-auto h-[calc(100dvh-2rem)] max-h-none w-[90rem] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-3xl border border-neutral-200 bg-(--workbench-canvas) p-0 shadow-xl backdrop:bg-black/40 open:flex max-md:m-0 max-md:h-dvh max-md:w-full max-md:max-w-none max-md:rounded-none max-md:border-0 max-md:pt-[env(safe-area-inset-top)] max-md:pb-[env(safe-area-inset-bottom)]"></dialog>
274
205
 
275
206
  <!-- Before the bundle, and only until it loads: a module that throws while
276
207
  evaluating leaves a blank page, no handler installed and nothing in the
Binary file
Binary file
@@ -19,8 +19,6 @@
19
19
  { "src": "/app/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
20
20
  ],
21
21
  "shortcuts": [
22
- { "name": "Tasks", "url": "/app/#/tasks", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] },
23
- { "name": "Activity", "url": "/app/#/activity", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] },
24
22
  { "name": "Settings", "url": "/app/#/settings", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] }
25
23
  ]
26
24
  }
package/dist/web/push.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Notifications for the workbench that is not on screen. The rule is the
2
- // sidebar's unread dot, read a few seconds late: a client with the session
2
+ // drawer's unread dot, read a few seconds late: a client with the session
3
3
  // visible acks immediately, so "still unread" is precisely "nobody saw it".
4
4
  // The wire format is webpush.ts.
5
5
  import { sessionLabel } from "../core/identity.js";
@@ -9,15 +9,16 @@ import { serveStatic } from "@hono/node-server/serve-static";
9
9
  import { Hono } from "hono";
10
10
  import { compress } from "hono/compress";
11
11
  import { streamSSE } from "hono/streaming";
12
+ import { ChatCommandRefused } from "../core/chain.js";
12
13
  import { EventHub } from "../core/hub.js";
13
14
  import { logger } from "../log.js";
14
- import { QueueOperationError, Router } from "../core/router.js";
15
+ import { QueueOperationError, Router, SkillAmbiguous } from "../core/router.js";
15
16
  import { registerConfigRoutes } from "./config.js";
16
17
  import { registerExplorerRoutes } from "./explorer.js";
17
18
  import { registerPackageRoutes } from "./packages.js";
18
19
  import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes } from "./fs.js";
19
20
  import { guarded } from "./route.js";
20
- import { isThinkingLevel, SESSION_TITLE_MAX } from "../core/types.js";
21
+ import { CHAIN_FULL_TOKENS, isThinkingLevel } from "../core/types.js";
21
22
  import { saveInbound } from "../core/inbox.js";
22
23
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
23
24
  import {} from "./session-state.js";
@@ -31,7 +32,7 @@ async function queueResponse(action, status = 200) {
31
32
  }
32
33
  catch (err) {
33
34
  const code = err instanceof QueueOperationError
34
- ? err.reason === "draining" ? 503 : err.reason === "missing" ? 404 : 409
35
+ ? err.reason === "draining" ? 503 : 409
35
36
  : 404;
36
37
  return Response.json({ error: String(err) }, { status: code });
37
38
  }
@@ -100,7 +101,7 @@ function boundedWriter(stream, who) {
100
101
  }
101
102
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
102
103
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
103
- export function createServer({ factory, router, hub, sessions: state, config, packages, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, passkeys, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, taskSessions, channelOf, }) {
104
+ export function createServer({ factory, router, hub, sessions: state, config, packages, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, passkeys, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, parkedMessages, subscribed, taskSessions, leads, openItems, channelOf, continuous, }) {
104
105
  const app = new Hono();
105
106
  const epoch = randomUUID();
106
107
  // One frame shared by synchronous fan-out to every watching tab.
@@ -119,6 +120,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
119
120
  // elsewhere, and nothing could ever clear the mark (the ack needs the session
120
121
  // on screen).
121
122
  const workbenchOwn = (id) => (channelOf?.(id) ?? "web") === "web" && !(taskSessions?.().has(id) ?? false);
123
+ // Outside the continuous conversation only a turn the operator sent into is
124
+ // theirs to read: a lead's dispatch or callback turn reports through main.
125
+ const operatorSent = new Set();
126
+ const sentByOperator = (id) => void operatorSent.add(id);
122
127
  const runningNow = new Set();
123
128
  hub.subscribeWorkspace((e) => {
124
129
  if (e.type !== "session-state")
@@ -129,7 +134,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
129
134
  }
130
135
  if (!runningNow.delete(e.sessionId))
131
136
  return;
132
- if (!workbenchOwn(e.sessionId))
137
+ const theirs = operatorSent.delete(e.sessionId) || continuous.chainOf(e.sessionId) !== undefined;
138
+ if (!theirs || !workbenchOwn(e.sessionId))
133
139
  return;
134
140
  state.setUnread(e.sessionId, true);
135
141
  hub.emitWorkspace({ type: "sessions-changed" });
@@ -137,25 +143,16 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
137
143
  /** One query for a whole list, not one per row. */
138
144
  const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
139
145
  const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
140
- // Pi persists a session only once the first assistant message lands; until
141
- // then the rail lists it from here.
142
- const nascent = new Map();
143
- /** A session created and never messaged does not survive an eviction (Pi
144
- * persisted nothing) but is still in `nascent`; left alone, clicking it 404s
145
- * forever. Dropped here, and the 404 says what happened (§5). */
146
- const ensureLoadable = async (id) => {
147
- try {
148
- return await ensure(id);
149
- }
150
- catch (err) {
151
- if (String(err).includes("unknown session")) {
152
- nascent.delete(id);
153
- state.forget(id);
154
- hub.emitWorkspace({ type: "sessions-changed" });
155
- throw new Error(`session ${id} no longer exists — it never got a first reply, so nothing was persisted; its rail entry was removed`);
156
- }
157
- throw err;
158
- }
146
+ // An earlier member of the continuous conversation is read off disk and
147
+ // never opened, so it can be neither edited nor resumed.
148
+ const older = (id) => (continuous.chainOf(id)?.[0] ?? id) !== id;
149
+ const turnsOf = async (id) => {
150
+ if (!older(id))
151
+ return (await ensure(id)).history();
152
+ const turns = await factory.readHistory(id);
153
+ if (!turns)
154
+ throw new Error(`unknown session: ${id}`);
155
+ return turns;
159
156
  };
160
157
  // Concurrent consumers share one scan; nothing is cached past the last of them.
161
158
  let listing;
@@ -164,55 +161,34 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
164
161
  });
165
162
  const allSessions = async () => {
166
163
  const sessions = await listSessions();
167
- for (const s of sessions)
168
- nascent.delete(s.id);
169
- // A session created but never prompted would otherwise hold a working-set
170
- // slot forever.
171
- for (const [id, n] of nascent) {
172
- if (Date.now() - n.createdAt > 86_400_000) {
173
- nascent.delete(id);
174
- state.forget(id);
175
- }
176
- }
177
164
  const owned = taskSessions?.() ?? new Set();
178
- return [
179
- ...[...nascent].map(([id, n]) => ({ id, ...n })),
180
- ...sessions.filter((s) => !owned.has(s.id)),
181
- ];
165
+ return sessions.filter((s) => !owned.has(s.id));
182
166
  };
183
- // `rank` is the place in the rail's working set; `modified` is for the
184
- // row's tooltip and orders nothing.
185
- const present = (s, own, active) => ({
167
+ // `modified` is for the row's tooltip and orders nothing.
168
+ const leadOf = (lead) => (lead ? { phase: lead.phase, ...(lead.runLive ? { runLive: true } : {}), ...(lead.designOpen ? { designOpen: true } : {}) } : {});
169
+ const present = (s, own, active, lead) => ({
186
170
  ...s,
187
- ...(own?.rank === undefined ? {} : { rank: own.rank }),
188
171
  state: router.stateOf(s.id) ?? "idle",
189
172
  unread: own?.unread ?? false,
190
173
  channel: channelOf?.(s.id) ?? "web",
191
174
  activeRuns: active.get(s.id) ?? 0,
192
- });
193
- // The rail's top rows are maintained here and nowhere else: a session a
194
- // human speaks to — or creates, below — and that is not in the working set
195
- // already takes the front slot (web/session-state.ts). No route — there is no
196
- // gesture to make, and an IM message has no browser to make it from.
197
- router.onSpokenTo((id) => {
198
- if (state.promote(id))
199
- hub.emitWorkspace({ type: "sessions-changed" });
175
+ ...leadOf(lead.get(s.id)),
200
176
  });
201
177
  app.get("/api/sessions", async (c) => {
202
178
  const flags = state.flags();
203
179
  const active = activeRuns();
204
- return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
180
+ const lead = leads?.() ?? new Map();
181
+ return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active, lead)));
205
182
  });
206
183
  // One session by id, the listing's filters aside: a task run's own session is
207
- // never a row (allSessions), and the pane that opened it from Runs still has
184
+ // never a row (allSessions), and the pane that opened it from a run card still has
208
185
  // a header to name and a session info panel to fill.
209
186
  app.get("/api/sessions/:id", async (c) => {
210
187
  const id = c.req.param("id");
211
- const n = nascent.get(id);
212
- const summary = n ? { id, ...n } : (await listSessions()).find((s) => s.id === id);
188
+ const summary = (await listSessions()).find((s) => s.id === id);
213
189
  if (!summary)
214
190
  return c.json({ error: `no session ${id}` }, 404);
215
- return c.json(present(summary, state.flags().get(id), activeRuns()));
191
+ return c.json(present(summary, state.flags().get(id), activeRuns(), leads?.() ?? new Map()));
216
192
  });
217
193
  // What was said, across every session: the palette's Messages section. The
218
194
  // factory owns the index and the ranking; an empty query is an empty answer,
@@ -221,24 +197,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
221
197
  const q = (c.req.query("q") ?? "").trim();
222
198
  return c.json({ hits: q ? await factory.search(q) : [] });
223
199
  });
224
- app.post("/api/sessions", async (c) => {
225
- const body = await c.req.json().catch(() => ({}));
226
- // Never in pier's own directory.
227
- if (typeof body.cwd !== "string" || !body.cwd)
228
- return c.json({ error: "cwd required" }, 400);
229
- // Resolved like the seam does (agent/pi.ts), or the nascent row is the one
230
- // place a symlinked directory still looks like a project of its own.
231
- const cwd = await realpath(body.cwd).catch(() => body.cwd);
232
- const session = await factory.create({ cwd });
233
- const createdAt = Date.now();
234
- nascent.set(session.id, { cwd, createdAt });
235
- router.attach({ channelId: "web", conversationId: session.id }, session);
236
- // Created is as good as spoken to: a row born below the working set would
237
- // jump on the first message.
238
- state.promote(session.id);
239
- hub.emitWorkspace({ type: "sessions-changed" });
240
- return c.json({ id: session.id }, 201);
241
- });
242
200
  app.post("/api/sessions/:id/read", (c) => {
243
201
  const id = c.req.param("id");
244
202
  if (state.unread(id)) {
@@ -253,7 +211,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
253
211
  app.use("/api/sessions/:id/turns/:index/steps", compress());
254
212
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
255
213
  const id = c.req.param("id");
256
- const session = await ensureLoadable(id);
214
+ // Nothing live to snapshot: no cursor, no state, the transcript and its run cards.
215
+ if (older(id))
216
+ return c.json({ turns: (await turnsOf(id)).map(slim), backgroundRuns: backgroundRuns?.(id) ?? [], skills: [], readonly: true });
217
+ const session = await ensure(id);
257
218
  // Async seam reads can straddle an event. Never label older content with a
258
219
  // newer cursor, and never spin indefinitely if the session stays busy.
259
220
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -268,10 +229,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
268
229
  state: session.state,
269
230
  context: session.contextUsage ?? null,
270
231
  thinkingLevel: session.thinkingLevel,
271
- queue,
272
- queueRecovery: router.recoveryOf(id),
273
- queueUncertain: router.queueUncertain(id),
232
+ queue: { ...queue, parked: parkedMessages?.(id) ?? [] },
274
233
  backgroundRuns: backgroundRuns?.(id) ?? [],
234
+ skills: session.skills(),
275
235
  });
276
236
  }
277
237
  log.warn(`snapshot for ${id} changed during all 3 reads`);
@@ -285,7 +245,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
285
245
  const index = Number(c.req.param("index"));
286
246
  if (!Number.isInteger(index) || index < 0)
287
247
  return c.json({ error: "index required" }, 400);
288
- const turn = (await (await ensure(c.req.param("id"))).history())[index];
248
+ const turn = (await turnsOf(c.req.param("id")))[index];
289
249
  if (!turn)
290
250
  return c.json({ error: `no turn at index ${index}` }, 404);
291
251
  return c.json({ steps: turn.steps ?? [] });
@@ -371,16 +331,52 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
371
331
  return c.json({ error: "text required" }, 400);
372
332
  }
373
333
  const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
374
- const { sessionId } = await router.dispatch({
375
- key: { channelId: "web", conversationId: id },
376
- senderId: "web",
377
- // Named: in a session also reached from a group chat, an unheaded message
378
- // is attributed to whoever spoke last (core/identity.ts).
379
- sender: { id: "web", name: "operator" },
380
- text: body.text,
381
- mode,
382
- });
383
- return c.json({ sessionId }, 202);
334
+ sentByOperator(id);
335
+ try {
336
+ const { sessionId } = await router.dispatch({
337
+ key: { channelId: "web", conversationId: id },
338
+ senderId: "web",
339
+ // Named: in a session also reached from a group chat, an unheaded message
340
+ // is attributed to whoever spoke last (core/identity.ts).
341
+ sender: { id: "web", name: "operator" },
342
+ text: body.text,
343
+ mode,
344
+ });
345
+ return c.json({ sessionId }, 202);
346
+ }
347
+ catch (err) {
348
+ if (err instanceof SkillAmbiguous)
349
+ return c.json({ error: err.message }, 409);
350
+ throw err;
351
+ }
352
+ });
353
+ /** A rotation re-lists every surface. */
354
+ const reached = (head) => {
355
+ if (head.rotated)
356
+ hub.emitWorkspace({ type: "sessions-changed" });
357
+ return head;
358
+ };
359
+ // The chain, newest first; the client pages back through it with /history.
360
+ app.get("/api/continuous", (c) => c.json({ chain: continuous.members(), rotateAt: CHAIN_FULL_TOKENS }));
361
+ // The drawer's run rows.
362
+ app.get("/api/continuous/open", (c) => c.json(openItems?.() ?? { items: [], unlisted: [], designs: [] }));
363
+ // ⋯ → Status: `/status`'s answer, without posting it.
364
+ app.get("/api/continuous/status", (c) => c.json(continuous.status()));
365
+ // The alias send: the head is resolved (and rotated) here, so a rotation
366
+ // between the client's snapshot and its send cannot land on an old head.
367
+ guarded(app, "POST", "/api/continuous/messages", 400, async (c) => {
368
+ const body = await c.req.json().catch(() => null);
369
+ if (!body || typeof body.text !== "string" || !body.text.trim())
370
+ return c.json({ error: "text required" }, 400);
371
+ const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
372
+ try {
373
+ return c.json(reached(await continuous.send({ senderId: "web", sender: { id: "web", name: "operator" }, text: body.text, mode })), 202);
374
+ }
375
+ catch (err) {
376
+ if (err instanceof ChatCommandRefused || err instanceof SkillAmbiguous)
377
+ return c.json({ error: err.message }, 409);
378
+ throw err;
379
+ }
384
380
  });
385
381
  // Edit a user turn: rewind to just before it — dropping every turn after it —
386
382
  // then re-dispatch the edited text.
@@ -394,6 +390,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
394
390
  // Before touching anything: a refused dispatch must not cost a rewound transcript.
395
391
  if (router.isDraining())
396
392
  return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
393
+ if (older(id))
394
+ return c.json({ error: "an earlier session of the continuous conversation is read-only" }, 409);
397
395
  const session = await ensure(id);
398
396
  if (session.state === "streaming")
399
397
  return c.json({ error: "busy — stop the turn first" }, 409);
@@ -405,6 +403,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
405
403
  await session.rewindToUserTurn(index);
406
404
  // The rewind took the speaker headers out of the context too.
407
405
  router.forgetSender(id);
406
+ sentByOperator(id);
408
407
  await router.dispatch({
409
408
  key: { channelId: "web", conversationId: id },
410
409
  senderId: "web",
@@ -414,7 +413,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
414
413
  });
415
414
  return c.json({ ok: true }, 202);
416
415
  });
417
- // Core owns exclusion and retains originals through the asynchronous handoff.
416
+ // Core owns exclusion and reports a failed delivery with the originals.
418
417
  guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
419
418
  const id = c.req.param("id");
420
419
  const body = await c.req.json().catch(() => null);
@@ -422,6 +421,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
422
421
  if (mode !== "steer" && mode !== "restart") {
423
422
  return c.json({ error: "mode must be steer or restart" }, 400);
424
423
  }
424
+ sentByOperator(id);
425
425
  return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
426
426
  });
427
427
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
@@ -431,32 +431,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
431
431
  return { messages: [...steering, ...followUp] };
432
432
  });
433
433
  });
434
- guarded(app, "POST", "/api/sessions/:id/queue/recovery/:batchId/ack", 404, async (c) => queueResponse(async () => {
435
- router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
436
- return { ok: true };
437
- }));
438
- // Refused while streaming: Pi's compaction aborts a running turn, and losing
439
- // one is not what the button offered. The result arrives on the stream as
440
- // `context-compacted`.
441
- guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
442
- const session = await ensure(c.req.param("id"));
443
- if (session.state === "streaming")
444
- return c.json({ error: "busy — stop the turn first" }, 409);
445
- // The check above is not the lock: two clicks pass it on the same tick, and
446
- // the seam's refusal must keep the "not now" status, not read as "no such session".
447
- return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
448
- });
449
- // Not refused while streaming: a rename has nothing to do with the turn running.
450
- guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
451
- const body = await c.req.json().catch(() => null);
452
- if (typeof body?.name !== "string")
453
- return c.json({ error: "name required" }, 400);
454
- const id = c.req.param("id");
455
- await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
456
- // The transcript is the answer; the event tells surfaces to re-read it.
457
- hub.emitWorkspace({ type: "sessions-changed" });
458
- return c.json({ ok: true });
459
- });
460
434
  app.post("/api/sessions/:id/abort", async (c) => {
461
435
  const id = c.req.param("id");
462
436
  await router.abort(id);
@@ -535,9 +509,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
535
509
  onUnlocked,
536
510
  onSettingsChanged: () => recycle("instance settings"),
537
511
  passkeys,
512
+ subscribed,
538
513
  });
539
514
  registerProviderRoutes(app, providers, () => recycle("provider configuration"));
540
- registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
515
+ registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file"), subscribed });
541
516
  registerPackageRoutes(app, { factory, packages, onConfigWritten: () => recycle("the package registry") });
542
517
  registerFsRoutes(app);
543
518
  registerExplorerRoutes(app);