@timqi/pier 0.0.9 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
|
@@ -33,8 +33,8 @@
|
|
|
33
33
|
which style.css pays for with the safe-area inset. -->
|
|
34
34
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
35
35
|
<title>Pier</title>
|
|
36
|
-
<script type="module" crossorigin src="/assets/index-
|
|
37
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
36
|
+
<script type="module" crossorigin src="/assets/index-DVUvzNK1.js"></script>
|
|
37
|
+
<link rel="stylesheet" crossorigin href="/assets/index-2E9_cwpg.css">
|
|
38
38
|
</head>
|
|
39
39
|
<!-- The document never scrolls: this is a fixed-viewport workbench, and every
|
|
40
40
|
scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
Console view keeps the same way back to the drawer as a chat does. -->
|
|
130
130
|
<header id="mobile-bar" class="flex h-12 flex-none items-center gap-1 border-b border-neutral-200 px-1.5 md:hidden">
|
|
131
131
|
<button type="button" id="drawer-toggle" aria-label="Open sidebar"
|
|
132
|
-
class="flex h-10 w-10 flex-none cursor-pointer items-center justify-center rounded-md text-neutral-500 active:bg-neutral-200">
|
|
132
|
+
class="relative flex h-10 w-10 flex-none cursor-pointer items-center justify-center rounded-md text-neutral-500 active:bg-neutral-200">
|
|
133
133
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" class="h-5 w-5">
|
|
134
134
|
<path d="M2.5 4h11M2.5 8h11M2.5 12h11" />
|
|
135
135
|
</svg>
|
|
@@ -237,11 +237,8 @@
|
|
|
237
237
|
<h3 class="font-semibold">New session</h3>
|
|
238
238
|
<label class="flex flex-col gap-1 text-[13px] text-neutral-600">
|
|
239
239
|
Working directory
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
class="min-w-0 flex-1 rounded-md border border-neutral-300 px-2 py-1.5 font-mono text-[13px] focus:border-indigo-400 focus:outline-none" />
|
|
243
|
-
</span>
|
|
244
|
-
<datalist id="known-projects"></datalist>
|
|
240
|
+
<input id="new-cwd" type="text" required placeholder="/path/to/project"
|
|
241
|
+
class="w-full rounded-md border border-neutral-300 px-2 py-1.5 font-mono text-[13px] focus:border-indigo-400 focus:outline-none" />
|
|
245
242
|
</label>
|
|
246
243
|
<div class="flex justify-end gap-2">
|
|
247
244
|
<button type="button" id="new-cancel" class="btn text-[13px]">Cancel</button>
|
package/dist/web/public/sw.js
CHANGED
|
@@ -59,6 +59,10 @@ self.addEventListener("push", (event) => {
|
|
|
59
59
|
await self.registration.showNotification(title, {
|
|
60
60
|
body: data.body || "A turn finished.",
|
|
61
61
|
tag: data.tag || "pier",
|
|
62
|
+
// The tag collapses a session's turns into one banner; without this the
|
|
63
|
+
// second turn silently replaces the first — no sound, no re-alert, which
|
|
64
|
+
// reads as "notifications stopped working". Ignored where unsupported.
|
|
65
|
+
renotify: true,
|
|
62
66
|
icon: "/icon-192.png",
|
|
63
67
|
badge: "/icon-32.png",
|
|
64
68
|
timestamp: Date.now(),
|
package/dist/web/push.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// "still unread when the dust settled" is precisely "nobody saw it". One rule,
|
|
8
8
|
// one place — a second notion of attention would drift from the dot within a
|
|
9
9
|
// release.
|
|
10
|
+
import { readableTitle } from "../core/identity.js";
|
|
10
11
|
import { pierDb } from "../db.js";
|
|
11
12
|
import { logger } from "../log.js";
|
|
12
13
|
import { generateVapidKeys, sendPush, } from "./webpush.js";
|
|
@@ -20,6 +21,14 @@ const MAX_SUBSCRIPTIONS = 20;
|
|
|
20
21
|
* notification about something that just happened. */
|
|
21
22
|
const SETTLE_MS = 6_000;
|
|
22
23
|
const MAX_BODY_CHARS = 160;
|
|
24
|
+
/** What to call the session where there is room for one line. The title is put
|
|
25
|
+
* through the same reader the sidebar uses — untouched, a session titled by
|
|
26
|
+
* its first prompt announces itself on the lock screen as
|
|
27
|
+
* `[operator<web> 12:01]`. Falls back to the project directory, then to the
|
|
28
|
+
* fact that it is a session at all: a notification with no title reads as a
|
|
29
|
+
* browser bug rather than as an unnamed session — and a listing that could
|
|
30
|
+
* not answer must not silence the push. */
|
|
31
|
+
const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
23
32
|
/** Subscriptions and the instance's VAPID identity. Both are per-instance
|
|
24
33
|
* facts nobody edits by hand, so they live beside every other one. */
|
|
25
34
|
export class PushStore {
|
|
@@ -100,7 +109,7 @@ function parseTarget(body) {
|
|
|
100
109
|
return { endpoint, p256dh, auth };
|
|
101
110
|
}
|
|
102
111
|
export function registerPushRoutes(app, deps) {
|
|
103
|
-
const { store, hub, unread, channelOf,
|
|
112
|
+
const { store, hub, unread, channelOf, summary, publicUrl, settleMs = SETTLE_MS } = deps;
|
|
104
113
|
/** Who a push service should complain to. It has to be a mailto: or https:
|
|
105
114
|
* URL or Apple rejects the token outright, so an instance that never had
|
|
106
115
|
* its public URL set still needs an answer. */
|
|
@@ -187,12 +196,18 @@ export function registerPushRoutes(app, deps) {
|
|
|
187
196
|
log.debug(`no push for ${e.sessionId}: a client reported the turn as seen`);
|
|
188
197
|
return; // somebody has it on screen
|
|
189
198
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
199
|
+
// One async step before the send, so a failure in *either* half is
|
|
200
|
+
// reported: a notification nobody received and one nobody sent look
|
|
201
|
+
// identical from here, and "why did my phone stay quiet" is the only
|
|
202
|
+
// question this feature is ever asked (§5b).
|
|
203
|
+
void (async () => {
|
|
204
|
+
await deliver({
|
|
205
|
+
title: label(await summary(e.sessionId)),
|
|
206
|
+
body: preview(text) || "Turn finished.",
|
|
207
|
+
url: `/#/session/${encodeURIComponent(e.sessionId)}`,
|
|
208
|
+
tag: e.sessionId,
|
|
209
|
+
});
|
|
210
|
+
})().catch((err) => log.error(`delivering a push for ${e.sessionId} failed`, err));
|
|
196
211
|
}, settleMs);
|
|
197
212
|
// A pending notification must never hold a shutting-down process open.
|
|
198
213
|
timer.unref?.();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Which repository a project directory belongs to, and which branch it has
|
|
2
|
+
// checked out.
|
|
3
|
+
//
|
|
4
|
+
// One reason: worktrees. A parallel-work checkout is a sibling directory with
|
|
5
|
+
// the same repository behind it, so the rail drew one project per directory and
|
|
6
|
+
// a repo worked on three branches looked like three unrelated projects. The
|
|
7
|
+
// grouping fact is not the path — `<repo>.<branch>` is a naming convention
|
|
8
|
+
// nobody is obliged to follow — it is the common git dir every worktree of a
|
|
9
|
+
// repository shares, and git is the only thing that knows it.
|
|
10
|
+
import { execFile } from "node:child_process";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { logger } from "../log.js";
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const log = logger("web");
|
|
15
|
+
/** How long an answer stands. Repository identity never moves; the branch does,
|
|
16
|
+
* and a checkout switched under a session should say so within a minute. */
|
|
17
|
+
const TTL_MS = 60_000;
|
|
18
|
+
/** A probe that hangs must not hold a slot forever: this is a `rev-parse`. */
|
|
19
|
+
const TIMEOUT_MS = 5_000;
|
|
20
|
+
/** One `rev-parse` for both facts. `--path-format=absolute` applies to the
|
|
21
|
+
* path option that follows it, so the two answers come back in argument
|
|
22
|
+
* order: common dir, then branch. */
|
|
23
|
+
const probeGit = async (cwd) => {
|
|
24
|
+
const { stdout } = await run("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir", "--abbrev-ref", "HEAD"], { timeout: TIMEOUT_MS });
|
|
25
|
+
const [repo, branch] = stdout.split("\n").map((line) => line.trim());
|
|
26
|
+
if (!repo)
|
|
27
|
+
return null;
|
|
28
|
+
return { repo, ...(branch && branch !== "HEAD" ? { branch } : {}) };
|
|
29
|
+
};
|
|
30
|
+
export class RepoIndex {
|
|
31
|
+
onChange;
|
|
32
|
+
probe;
|
|
33
|
+
#cache = new Map();
|
|
34
|
+
#probing = new Set();
|
|
35
|
+
constructor(
|
|
36
|
+
/** Called when a probe changed what `get` answers, so the surface that
|
|
37
|
+
* already rendered the old grouping is told to ask again. */
|
|
38
|
+
onChange = () => { }, probe = probeGit) {
|
|
39
|
+
this.onChange = onChange;
|
|
40
|
+
this.probe = probe;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* What is known right now, and never a promise: the rail renders on the first
|
|
44
|
+
* paint, with one project per directory until the answer lands and `onChange`
|
|
45
|
+
* regroups it. A directory that is not a repository answers `undefined`
|
|
46
|
+
* forever, at the cost of one `rev-parse` a minute.
|
|
47
|
+
*/
|
|
48
|
+
get(cwd) {
|
|
49
|
+
const hit = this.#cache.get(cwd);
|
|
50
|
+
if (!hit || Date.now() - hit.at > TTL_MS)
|
|
51
|
+
void this.#refresh(cwd);
|
|
52
|
+
return hit?.info ?? undefined;
|
|
53
|
+
}
|
|
54
|
+
async #refresh(cwd) {
|
|
55
|
+
if (this.#probing.has(cwd))
|
|
56
|
+
return;
|
|
57
|
+
this.#probing.add(cwd);
|
|
58
|
+
try {
|
|
59
|
+
const info = await this.probe(cwd).catch(() => null);
|
|
60
|
+
const before = this.#cache.get(cwd)?.info;
|
|
61
|
+
this.#cache.set(cwd, { at: Date.now(), info });
|
|
62
|
+
if (before?.repo !== info?.repo || before?.branch !== info?.branch)
|
|
63
|
+
this.onChange();
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Not a git failure — those are the `null` above. Something in the probe
|
|
67
|
+
// itself, which would otherwise leave the directory retried every read.
|
|
68
|
+
log.warn(`repository probe for ${cwd} failed`, err);
|
|
69
|
+
this.#cache.set(cwd, { at: Date.now(), info: null });
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
this.#probing.delete(cwd);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -14,8 +14,11 @@ import { Router } from "../core/router.js";
|
|
|
14
14
|
import { registerExplorerRoutes } from "./explorer.js";
|
|
15
15
|
import { guarded, registerFileRoutes } from "./files.js";
|
|
16
16
|
import { isThinkingLevel } from "../core/types.js";
|
|
17
|
+
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
17
18
|
import { saveInbound } from "../core/inbox.js";
|
|
18
19
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
20
|
+
import { RepoIndex } from "./repos.js";
|
|
21
|
+
import {} from "./session-state.js";
|
|
19
22
|
import { registerInstanceRoutes } from "./instance.js";
|
|
20
23
|
import { registerProviderRoutes } from "./providers.js";
|
|
21
24
|
const log = logger("web");
|
|
@@ -37,7 +40,7 @@ export const withTabPrefix = (html, prefix) => prefix
|
|
|
37
40
|
const HEARTBEAT_MS = 15_000;
|
|
38
41
|
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
39
42
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
40
|
-
export function createServer({ factory, router, hub, sessions: state, config, providers, settings,
|
|
43
|
+
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
|
|
41
44
|
const app = new Hono();
|
|
42
45
|
// A finished turn marks its session unread until some client reports it was
|
|
43
46
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
@@ -57,6 +60,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
57
60
|
state.setUnread(e.sessionId, true);
|
|
58
61
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
59
62
|
});
|
|
63
|
+
// Repository identity per project directory, refreshed off the request path.
|
|
64
|
+
const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
|
|
60
65
|
/** Background runs this session launched that are still in flight. */
|
|
61
66
|
const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
|
|
62
67
|
/** The web channel's session for `id` — every session route resolves here. */
|
|
@@ -65,39 +70,80 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
65
70
|
// only once the first assistant message lands. Merged into the list below
|
|
66
71
|
// so every client sees a new session immediately; dropped once Pi lists it.
|
|
67
72
|
const nascent = new Map();
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
/** `ensure`, plus ghost cleanup. A session created and never messaged does
|
|
74
|
+
* not survive a restart or an eviction (Pi persisted nothing), but while
|
|
75
|
+
* this process lives it is in `nascent` and therefore in the rail — left
|
|
76
|
+
* alone, clicking it 404s forever. The load path is where a ghost is
|
|
77
|
+
* discovered, so it is where the entry and its pin are dropped and every
|
|
78
|
+
* rail told; the 404 then says what happened instead of looking like a
|
|
79
|
+
* crash (§5b). */
|
|
80
|
+
const ensureLoadable = async (id) => {
|
|
81
|
+
try {
|
|
82
|
+
return await ensure(id);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (String(err).includes("unknown session")) {
|
|
86
|
+
nascent.delete(id);
|
|
87
|
+
state.forget(id);
|
|
88
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
89
|
+
throw new Error(`session ${id} no longer exists — it never got a first reply, so nothing was persisted; its rail entry was removed`);
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// A listing stats every session file and parses whatever grew — milliseconds
|
|
95
|
+
// warm, one scan cold. Concurrent consumers share it, whatever the factory
|
|
96
|
+
// behind the seam retains of its own; nothing here is cached past the last
|
|
97
|
+
// of them.
|
|
70
98
|
let listing;
|
|
71
|
-
|
|
72
|
-
const listSessions = () => listing ??= factory.list()
|
|
73
|
-
.then((rows) => {
|
|
74
|
-
state.remember(rows);
|
|
75
|
-
return rows;
|
|
76
|
-
})
|
|
77
|
-
.finally(() => {
|
|
99
|
+
const listSessions = () => listing ??= factory.list().finally(() => {
|
|
78
100
|
listing = undefined;
|
|
79
101
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
102
|
+
/** Every session a surface may show: what Pi has written, plus the ones
|
|
103
|
+
* created here that it has not persisted yet. */
|
|
104
|
+
const allSessions = async () => {
|
|
105
|
+
const sessions = await listSessions();
|
|
106
|
+
for (const s of sessions)
|
|
107
|
+
nascent.delete(s.id);
|
|
108
|
+
// A session created but never prompted would otherwise be listed forever.
|
|
109
|
+
for (const [id, n] of nascent)
|
|
110
|
+
if (Date.now() - n.createdAt > 86_400_000)
|
|
111
|
+
nascent.delete(id);
|
|
112
|
+
return [
|
|
113
|
+
...[...nascent].map(([id, n]) => ({ id, ...n })),
|
|
114
|
+
...sessions,
|
|
115
|
+
];
|
|
116
|
+
};
|
|
117
|
+
// One session as every list renders it: the summary, what the workbench
|
|
118
|
+
// decided about it, and what is true of it right now.
|
|
119
|
+
// The listing's `modified` is dropped rather than forwarded: it dates a
|
|
120
|
+
// transcript, and no surface renders it.
|
|
121
|
+
const present = ({ modified: _drop, ...s }, own) => {
|
|
122
|
+
return {
|
|
123
|
+
...s,
|
|
124
|
+
...(own?.sort === undefined ? {} : { sort: own.sort }),
|
|
125
|
+
...(own?.projectSort === undefined ? {} : { projectSort: own.projectSort }),
|
|
126
|
+
// Which repository the directory belongs to, on every list and not only
|
|
127
|
+
// on the rail's: All sessions replaces the client's whole list, so a row
|
|
128
|
+
// without it there un-groups the worktrees the rail had just grouped.
|
|
129
|
+
// Whatever is known now — the probe is never waited on, and its answer
|
|
130
|
+
// arrives as a `sessions-changed`.
|
|
131
|
+
...repos.get(s.cwd),
|
|
132
|
+
state: router.stateOf(s.id) ?? "idle",
|
|
133
|
+
listed: own?.pinned ?? false,
|
|
134
|
+
unread: own?.unread ?? false,
|
|
135
|
+
channel: channelOf?.(s.id) ?? "web",
|
|
136
|
+
activeRuns: activeRuns(s.id),
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
// The rail: the same rows as the full list, minus everything Projects is not
|
|
140
|
+
// showing. One source, one shape — the rail used to read a second copy of
|
|
141
|
+
// the summaries out of SQLite, which is what had to be kept in step.
|
|
91
142
|
app.get("/api/projects", async (c) => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
// Do not retry on every request, and do not clear a pin whose transcript
|
|
97
|
-
// happened to be unreadable. A later explicit full listing can repair it.
|
|
98
|
-
projectBackfillNeeded = false;
|
|
99
|
-
}
|
|
100
|
-
return c.json(state.projects().map(({ sort, projectSort, ...s }) => present(s, true, s.unread, { sort, projectSort })));
|
|
143
|
+
const flags = state.flags();
|
|
144
|
+
return c.json((await allSessions())
|
|
145
|
+
.map((s) => present(s, flags.get(s.id)))
|
|
146
|
+
.filter((row) => row.listed));
|
|
101
147
|
});
|
|
102
148
|
// One drag, one write of the list that changed: the projects, or one
|
|
103
149
|
// project's sessions. Whole lists rather than a move — the client has just
|
|
@@ -120,21 +166,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
120
166
|
return c.json({ ok: true });
|
|
121
167
|
});
|
|
122
168
|
app.get("/api/sessions", async (c) => {
|
|
123
|
-
const sessions = await listSessions();
|
|
124
|
-
for (const s of sessions)
|
|
125
|
-
nascent.delete(s.id);
|
|
126
|
-
// A session created but never prompted would otherwise be listed forever.
|
|
127
|
-
for (const [id, n] of nascent)
|
|
128
|
-
if (Date.now() - n.createdAt > 86_400_000)
|
|
129
|
-
nascent.delete(id);
|
|
130
169
|
const flags = state.flags();
|
|
131
|
-
return c.json(
|
|
132
|
-
const row = flags.get(s.id);
|
|
133
|
-
return present(s, row?.pinned ?? false, row?.unread ?? false, {
|
|
134
|
-
sort: row?.sort,
|
|
135
|
-
projectSort: row?.projectSort,
|
|
136
|
-
});
|
|
137
|
-
}));
|
|
170
|
+
return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
|
|
138
171
|
});
|
|
139
172
|
app.post("/api/sessions", async (c) => {
|
|
140
173
|
const body = await c.req.json().catch(() => ({}));
|
|
@@ -146,7 +179,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
146
179
|
nascent.set(session.id, { cwd: body.cwd, createdAt });
|
|
147
180
|
router.attach({ channelId: "web", conversationId: session.id }, session);
|
|
148
181
|
// Created here = part of the workspace; pinning is what Projects lists.
|
|
149
|
-
state.pin(
|
|
182
|
+
state.pin(session.id, body.cwd, true);
|
|
150
183
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
151
184
|
return c.json({ id: session.id }, 201);
|
|
152
185
|
});
|
|
@@ -162,20 +195,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
162
195
|
});
|
|
163
196
|
app.post("/api/sessions/:id/pin", async (c) => {
|
|
164
197
|
const body = await c.req.json().catch(() => null);
|
|
165
|
-
if (typeof body?.pinned !== "boolean"
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
createdAt: body.createdAt,
|
|
177
|
-
...(body.title ? { title: body.title.slice(0, 80) } : {}),
|
|
178
|
-
}, body.pinned);
|
|
198
|
+
if (typeof body?.pinned !== "boolean")
|
|
199
|
+
return c.json({ error: "pinned required" }, 400);
|
|
200
|
+
const id = c.req.param("id");
|
|
201
|
+
// The directory comes from the listing, not from the client that clicked:
|
|
202
|
+
// it is the one fact this row keeps about the session, it is the key a
|
|
203
|
+
// project's manual place is stamped on, and the browser is not where a
|
|
204
|
+
// path should come from when the server already knows it.
|
|
205
|
+
const cwd = (await factory.find(id))?.cwd ?? nascent.get(id)?.cwd;
|
|
206
|
+
if (!cwd)
|
|
207
|
+
return c.json({ error: `session ${id} has no directory Pier can find` }, 404);
|
|
208
|
+
state.pin(id, cwd, body.pinned);
|
|
179
209
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
180
210
|
return c.json({ pinned: body.pinned });
|
|
181
211
|
});
|
|
@@ -188,7 +218,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
188
218
|
// deltas from SSE — transcript, live state, pending queue, model.
|
|
189
219
|
guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
|
|
190
220
|
const id = c.req.param("id");
|
|
191
|
-
const session = await
|
|
221
|
+
const session = await ensureLoadable(id);
|
|
192
222
|
return c.json({
|
|
193
223
|
turns: (await session.history()).map(slim),
|
|
194
224
|
lastSeq: hub.lastSeq(id),
|
|
@@ -283,11 +313,14 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
283
313
|
const { sessionId } = await router.dispatch({
|
|
284
314
|
key: { channelId: "web", conversationId: id },
|
|
285
315
|
senderId: "web",
|
|
316
|
+
// Named, not anonymous: a session reached from a group chat as well as
|
|
317
|
+
// from here attributes an unheaded message to whoever spoke last
|
|
318
|
+
// (core/identity.ts), which is the operator's own words in someone
|
|
319
|
+
// else's mouth.
|
|
320
|
+
sender: { id: "web", name: "operator" },
|
|
286
321
|
text: body.text,
|
|
287
322
|
mode,
|
|
288
323
|
});
|
|
289
|
-
if (state.title(id, body.text))
|
|
290
|
-
hub.emitWorkspace({ type: "sessions-changed" });
|
|
291
324
|
return c.json({ sessionId }, 202);
|
|
292
325
|
});
|
|
293
326
|
// Edit a user turn: rewind the transcript to just before it, then re-send
|
|
@@ -308,9 +341,13 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
308
341
|
if (session.state === "streaming")
|
|
309
342
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
310
343
|
await session.rewindToUserTurn(index);
|
|
344
|
+
// The rewind took the turns after this one out of the context, headers and
|
|
345
|
+
// all; what the model was told about who is speaking went with them.
|
|
346
|
+
router.forgetSender(id);
|
|
311
347
|
await router.dispatch({
|
|
312
348
|
key: { channelId: "web", conversationId: id },
|
|
313
349
|
senderId: "web",
|
|
350
|
+
sender: { id: "web", name: "operator" },
|
|
314
351
|
text: body.text,
|
|
315
352
|
mode: "auto",
|
|
316
353
|
});
|
|
@@ -336,6 +373,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
336
373
|
return c.json({ error: "queue is empty" }, 409);
|
|
337
374
|
if (mode === "restart")
|
|
338
375
|
await router.abort(id); // resolves once idle
|
|
376
|
+
// No sender here, unlike the other dispatches: the queued texts were
|
|
377
|
+
// headed when they were first dispatched — and in a shared session they
|
|
378
|
+
// are other speakers' words, which an operator header would claim.
|
|
339
379
|
await router.dispatch({
|
|
340
380
|
key: { channelId: "web", conversationId: id },
|
|
341
381
|
senderId: "web",
|
|
@@ -346,10 +386,48 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
346
386
|
});
|
|
347
387
|
// Recall: drop all pending queued messages and hand them back (composer restore).
|
|
348
388
|
guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
|
|
349
|
-
const
|
|
389
|
+
const id = c.req.param("id");
|
|
390
|
+
const session = await ensure(id);
|
|
350
391
|
const { steering, followUp } = await session.clearQueue();
|
|
392
|
+
// Those messages carried the header that told the session who is speaking,
|
|
393
|
+
// and they are going back to the composer instead of to the model.
|
|
394
|
+
if (steering.length || followUp.length)
|
|
395
|
+
router.forgetSender(id);
|
|
351
396
|
return c.json({ messages: [...steering, ...followUp] });
|
|
352
397
|
});
|
|
398
|
+
// Shrink the context on demand: Pi summarizes the older transcript away and
|
|
399
|
+
// the session continues from the summary. Refused while streaming, like the
|
|
400
|
+
// edit route above and for the same reason — Pi's own compaction aborts a
|
|
401
|
+
// running turn to do it, and losing a turn is not what the button offered.
|
|
402
|
+
// The result is not in this response: it arrives on the session's stream as
|
|
403
|
+
// `context-compacted` (agent/events.ts), which is also the only place the
|
|
404
|
+
// automatic compaction can be seen.
|
|
405
|
+
guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
|
|
406
|
+
const session = await ensure(c.req.param("id"));
|
|
407
|
+
if (session.state === "streaming")
|
|
408
|
+
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
409
|
+
// The check above is a courtesy, not the lock: two clicks pass it on the
|
|
410
|
+
// same tick, so the seam refuses the second one (agent/pi.ts) and its
|
|
411
|
+
// refusal keeps the status this route already uses for "not now" — a 404
|
|
412
|
+
// from `guarded` would have read as "no such session".
|
|
413
|
+
return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
|
|
414
|
+
});
|
|
415
|
+
// A name, so a title is what you called it instead of the first 80
|
|
416
|
+
// characters you happened to type. Not refused while streaming: a rename has
|
|
417
|
+
// nothing to do with the turn running, and the transcript takes an append.
|
|
418
|
+
guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
|
|
419
|
+
const body = await c.req.json().catch(() => null);
|
|
420
|
+
if (typeof body?.name !== "string")
|
|
421
|
+
return c.json({ error: "name required" }, 400);
|
|
422
|
+
const id = c.req.param("id");
|
|
423
|
+
await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
|
|
424
|
+
// Nothing to write and nothing to report: the name went into the
|
|
425
|
+
// transcript, which is what every list reads. The event is how the
|
|
426
|
+
// surfaces learn to re-read it, and the seam dropped its retained scan on
|
|
427
|
+
// the way out so the re-read sees the new name.
|
|
428
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
429
|
+
return c.json({ ok: true });
|
|
430
|
+
});
|
|
353
431
|
app.post("/api/sessions/:id/abort", async (c) => {
|
|
354
432
|
const id = c.req.param("id");
|
|
355
433
|
await router.abort(id);
|
|
@@ -423,7 +501,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
423
501
|
updates,
|
|
424
502
|
updater,
|
|
425
503
|
secrets,
|
|
426
|
-
|
|
504
|
+
catalog,
|
|
505
|
+
names,
|
|
506
|
+
onToolsChanged,
|
|
507
|
+
validateCustomTools,
|
|
427
508
|
onUnlocked,
|
|
428
509
|
onSettingsChanged: () => recycle("instance settings"),
|
|
429
510
|
});
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// What the workbench decided about a session, and nothing a transcript already
|
|
2
|
+
// knows. Ownership of a row in Projects (`pinned`), a finished turn nobody has
|
|
3
|
+
// looked at (`unread`), and the two places a hand put it in (`sort`,
|
|
4
|
+
// `project_sort`).
|
|
5
|
+
//
|
|
6
|
+
// It used to mirror the summary too — cwd, title, created_at, last_active —
|
|
7
|
+
// because listing sessions meant parsing every transcript on disk (~237ms) and
|
|
8
|
+
// the rail could not pay that on every read. agent/listing.ts made a listing
|
|
9
|
+
// cheap, so the mirror bought nothing and cost two stores kept in step: a
|
|
10
|
+
// backfill gate, a repair pass on every full listing, a touch at the end of
|
|
11
|
+
// every turn, a title write on the first prompt, a second write on rename.
|
|
12
|
+
// Deriving beats syncing; all of it is gone.
|
|
13
|
+
//
|
|
14
|
+
// `cwd` stayed, and is not a mirror: it is the key a project's manual place is
|
|
15
|
+
// stamped on (`reorder`, and the sibling lookup in `pin`), and a session's
|
|
16
|
+
// working directory is fixed when its transcript is created — an immutable key
|
|
17
|
+
// needs no synchronising.
|
|
4
18
|
//
|
|
5
19
|
// One row per session rather than two JSON files: the unread flag is written at
|
|
6
20
|
// the end of every turn, and rewriting a whole file on each of those writes
|
|
@@ -20,20 +34,20 @@ export class SessionStateStore {
|
|
|
20
34
|
this.#db.prepare(`INSERT INTO session_state(session_id, unread) VALUES (?, ?)
|
|
21
35
|
ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`).run(sessionId, unread ? 1 : 0);
|
|
22
36
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
37
|
+
/** Projects takes the session, or gives it up. Membership lasts until a hand
|
|
38
|
+
* ends it: nothing expires, so what is pinned is what the rail draws until
|
|
39
|
+
* the ✓ on the row says otherwise. */
|
|
40
|
+
pin(sessionId, cwd, pinned) {
|
|
25
41
|
// A new session joins a project that already has a place in the list.
|
|
26
42
|
// Unranked it would sort on top — lifting the whole project with it, which
|
|
27
43
|
// is the jump manual order exists to stop.
|
|
28
|
-
const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(
|
|
29
|
-
this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd,
|
|
30
|
-
VALUES (?, ?, ?,
|
|
44
|
+
const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(cwd);
|
|
45
|
+
this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, project_sort)
|
|
46
|
+
VALUES (?, ?, ?, ?)
|
|
31
47
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
32
48
|
pinned = excluded.pinned,
|
|
33
49
|
cwd = excluded.cwd,
|
|
34
|
-
|
|
35
|
-
created_at = excluded.created_at,
|
|
36
|
-
project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt, sibling?.rank ?? null);
|
|
50
|
+
project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(sessionId, pinned ? 1 : 0, cwd, sibling?.rank ?? null);
|
|
37
51
|
}
|
|
38
52
|
/** One drag = one write of the whole list it reordered: index is the place.
|
|
39
53
|
* `sessions` are ids (a session's place inside its project), `projects` are
|
|
@@ -46,41 +60,12 @@ export class SessionStateStore {
|
|
|
46
60
|
order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
|
|
47
61
|
});
|
|
48
62
|
}
|
|
49
|
-
/**
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
ORDER BY created_at DESC`).all();
|
|
56
|
-
return rows.map(({ title, unread, sort, projectSort, ...row }) => ({
|
|
57
|
-
...row,
|
|
58
|
-
...(title ? { title } : {}),
|
|
59
|
-
...(sort === null ? {} : { sort }),
|
|
60
|
-
...(projectSort === null ? {} : { projectSort }),
|
|
61
|
-
unread: unread === 1,
|
|
62
|
-
}));
|
|
63
|
-
}
|
|
64
|
-
/** What to call a session where there is room for one line — a push
|
|
65
|
-
* notification's title. Falls back to the project directory, then to the
|
|
66
|
-
* fact that it is a session at all: a notification with no title reads as a
|
|
67
|
-
* browser bug rather than as an unnamed session. */
|
|
68
|
-
name(sessionId) {
|
|
69
|
-
const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
|
|
70
|
-
return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
71
|
-
}
|
|
72
|
-
needsProjectBackfill() {
|
|
73
|
-
return this.#db.prepare("SELECT 1 FROM session_state WHERE pinned = 1 AND (cwd IS NULL OR created_at IS NULL) LIMIT 1").get() !== undefined;
|
|
74
|
-
}
|
|
75
|
-
/** A full listing is rare; use it to repair metadata for rows we already own. */
|
|
76
|
-
remember(summaries) {
|
|
77
|
-
const update = this.#db.prepare(`UPDATE session_state SET
|
|
78
|
-
cwd = ?, title = COALESCE(?, title), created_at = ?
|
|
79
|
-
WHERE session_id = ?`);
|
|
80
|
-
this.#tx(() => {
|
|
81
|
-
for (const s of summaries)
|
|
82
|
-
update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
|
|
83
|
-
});
|
|
63
|
+
/** Drop a session's organization row entirely — pin, order, unread. For
|
|
64
|
+
* ghosts: Pi persists a session only once its first assistant reply lands,
|
|
65
|
+
* so a created-and-never-messaged one cannot be resumed, and its remembered
|
|
66
|
+
* row would otherwise sit in the rail 404ing forever. */
|
|
67
|
+
forget(sessionId) {
|
|
68
|
+
this.#db.prepare("DELETE FROM session_state WHERE session_id = ?").run(sessionId);
|
|
84
69
|
}
|
|
85
70
|
/** All-or-nothing: a half-written order is a list nobody arranged. */
|
|
86
71
|
#tx(run) {
|
|
@@ -94,6 +79,8 @@ export class SessionStateStore {
|
|
|
94
79
|
throw err;
|
|
95
80
|
}
|
|
96
81
|
}
|
|
82
|
+
/** What this store knows about the sessions it knows anything about, for a
|
|
83
|
+
* caller holding the listing. */
|
|
97
84
|
flags() {
|
|
98
85
|
const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
|
|
99
86
|
FROM session_state WHERE pinned = 1 OR unread = 1`).all();
|
|
@@ -104,9 +91,4 @@ export class SessionStateStore {
|
|
|
104
91
|
...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
|
|
105
92
|
}]));
|
|
106
93
|
}
|
|
107
|
-
/** The first prompt supplies the title of a newly-created pinned session. */
|
|
108
|
-
title(sessionId, text) {
|
|
109
|
-
const result = this.#db.prepare("UPDATE session_state SET title = ? WHERE session_id = ? AND pinned = 1 AND title IS NULL").run(text.trim().slice(0, 80), sessionId);
|
|
110
|
-
return result.changes > 0;
|
|
111
|
-
}
|
|
112
94
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The shapes web's HTTP answers carry beyond the stores they come from — the
|
|
2
|
+
// area's own wire vocabulary, and the one file in web/ the browser may import
|
|
3
|
+
// type-only (docs/architecture.md, same rule as tasks/types.ts). Nothing here
|
|
4
|
+
// runs: no imports, no values, so a page pays nothing for reading it.
|
|
5
|
+
export {};
|