@timqi/pier 0.0.21 → 0.0.23
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 +4 -5
- package/dist/agent/config-sync.js +124 -92
- package/dist/boards/boards.js +6 -2
- package/dist/db.js +12 -3
- package/dist/main.js +14 -14
- package/dist/service.js +17 -7
- package/dist/settings.js +0 -26
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +4 -0
- package/dist/tasks/groups.js +2 -1
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/tool.js +70 -13
- package/dist/web/auth.js +1 -22
- package/dist/web/fs.js +2 -2
- package/dist/web/instance.js +6 -12
- package/dist/web/public/assets/activity-atEIVnGA.js +5 -0
- package/dist/web/public/assets/activity-atEIVnGA.js.br +0 -0
- package/dist/web/public/assets/activity-atEIVnGA.js.gz +0 -0
- package/dist/web/public/assets/{boards-DMVbV84c.js → boards-DSkfMEvN.js} +1 -1
- package/dist/web/public/assets/boards-DSkfMEvN.js.br +0 -0
- package/dist/web/public/assets/boards-DSkfMEvN.js.gz +0 -0
- package/dist/web/public/assets/{code-C86xKk2k.js → code-1nUOCxAb.js} +1 -1
- package/dist/web/public/assets/explorer-CS1kLPMl.js +5 -0
- package/dist/web/public/assets/explorer-CS1kLPMl.js.br +0 -0
- package/dist/web/public/assets/explorer-CS1kLPMl.js.gz +0 -0
- package/dist/web/public/assets/index-DP1-BW-q.css +2 -0
- package/dist/web/public/assets/index-DP1-BW-q.css.br +0 -0
- package/dist/web/public/assets/index-DP1-BW-q.css.gz +0 -0
- package/dist/web/public/assets/index-a7A8xfoi.js +83 -0
- package/dist/web/public/assets/index-a7A8xfoi.js.br +0 -0
- package/dist/web/public/assets/index-a7A8xfoi.js.gz +0 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js +1 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js.br +0 -0
- package/dist/web/public/assets/runs-bHSlJwXk.js.gz +0 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js +6 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js.br +0 -0
- package/dist/web/public/assets/settings-CrC-yuXh.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js +3 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js.br +0 -0
- package/dist/web/public/assets/task-runs-C2PHAhAf.js.gz +0 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js +4 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js.br +0 -0
- package/dist/web/public/assets/tasks-7S90iYr1.js.gz +0 -0
- package/dist/web/public/index.html +16 -24
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/server.js +4 -4
- package/docs/deploy.md +23 -15
- package/package.json +2 -9
- package/skills/pier-tasks/SKILL.md +24 -13
- package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
- package/dist/web/public/assets/activity-Cw0LRVNI.js +0 -5
- package/dist/web/public/assets/activity-Cw0LRVNI.js.br +0 -0
- package/dist/web/public/assets/activity-Cw0LRVNI.js.gz +0 -0
- package/dist/web/public/assets/boards-DMVbV84c.js.br +0 -0
- package/dist/web/public/assets/boards-DMVbV84c.js.gz +0 -0
- package/dist/web/public/assets/explorer-DTvsy5Rs.js +0 -5
- package/dist/web/public/assets/explorer-DTvsy5Rs.js.br +0 -0
- package/dist/web/public/assets/explorer-DTvsy5Rs.js.gz +0 -0
- package/dist/web/public/assets/ghostty-web-CvSZragH.js +0 -13
- package/dist/web/public/assets/ghostty-web-CvSZragH.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-CvSZragH.js.gz +0 -0
- package/dist/web/public/assets/index-DHxoYoeq.js +0 -79
- package/dist/web/public/assets/index-DHxoYoeq.js.br +0 -0
- package/dist/web/public/assets/index-DHxoYoeq.js.gz +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css +0 -2
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/runs-jY40JBP-.js +0 -1
- package/dist/web/public/assets/runs-jY40JBP-.js.br +0 -0
- package/dist/web/public/assets/runs-jY40JBP-.js.gz +0 -0
- package/dist/web/public/assets/settings-Dux7PIKF.js +0 -6
- package/dist/web/public/assets/settings-Dux7PIKF.js.br +0 -0
- package/dist/web/public/assets/settings-Dux7PIKF.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DKvtBVxS.js +0 -3
- package/dist/web/public/assets/task-runs-DKvtBVxS.js.br +0 -0
- package/dist/web/public/assets/task-runs-DKvtBVxS.js.gz +0 -0
- package/dist/web/public/assets/tasks-CqfrOVIt.js +0 -4
- package/dist/web/public/assets/tasks-CqfrOVIt.js.br +0 -0
- package/dist/web/public/assets/tasks-CqfrOVIt.js.gz +0 -0
- package/dist/web/public/assets/terminal-DL_0yEy7.js +0 -2
- package/dist/web/public/assets/terminal-DL_0yEy7.js.br +0 -0
- package/dist/web/public/assets/terminal-DL_0yEy7.js.gz +0 -0
- package/dist/web/terminal.js +0 -369
package/README.md
CHANGED
|
@@ -17,7 +17,6 @@ versioned from `0.0.1` on — earlier databases are not migrated. Read
|
|
|
17
17
|
## Requirements
|
|
18
18
|
|
|
19
19
|
- Node 24 or newer (`node:sqlite` is used unflagged)
|
|
20
|
-
- Linux: Python 3, `make` and a C/C++ compiler for `node-pty`
|
|
21
20
|
- A provider account (Anthropic, OpenAI, …) — configure its API key or OAuth
|
|
22
21
|
login from Console → Settings → Models after signing in
|
|
23
22
|
- A user-writable global npm prefix if `pier update` should update a service
|
|
@@ -61,8 +60,8 @@ an existing Pi setup:
|
|
|
61
60
|
PI_CODING_AGENT_DIR="$HOME/.pi/agent" pier serve
|
|
62
61
|
```
|
|
63
62
|
|
|
64
|
-
Pier exports that variable for the SDK, so anything it starts inherits it —
|
|
65
|
-
shell
|
|
63
|
+
Pier exports that variable for the SDK, so anything it starts inherits it — an
|
|
64
|
+
agent's shell included. A second Pier launched from there with its
|
|
66
65
|
own `PIER_HOME` derives its own agent directory rather than adopting the first
|
|
67
66
|
one's; setting `PI_CODING_AGENT_DIR` again on that command line still wins.
|
|
68
67
|
|
|
@@ -92,8 +91,8 @@ Console → Settings is the normal setup path:
|
|
|
92
91
|
Below it, the same tab switches on the command-line tools Pier manages —
|
|
93
92
|
`rtk`, `rg`, `fd`, `wt`, `jq`, or a tool of your own written as a
|
|
94
93
|
[ubix](https://github.com/timqi/ubix) block. A switch installs the binary
|
|
95
|
-
into `~/.pier/tools/bin`, which is first on the PATH every session
|
|
96
|
-
|
|
94
|
+
into `~/.pier/tools/bin`, which is first on the PATH every session and task
|
|
95
|
+
inherits, and a task you can read keeps them current.
|
|
97
96
|
|
|
98
97
|
On first credential access, Pier imports an existing `auth.json` into its sealed
|
|
99
98
|
store and renames the source to `auth.json.imported`. Literal provider keys left
|
|
@@ -1,54 +1,98 @@
|
|
|
1
1
|
// Credential-blind projection of the portable agent configuration.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/** Never leaves the instance, at any depth of a models.json provider: the
|
|
3
|
+
* credentials, and the endpoint they authenticate against — a sharing link is
|
|
4
|
+
* public. Every other field is metadata Pi's own schema already validates, so
|
|
5
|
+
* it travels as-is instead of through a field list chasing Pi's versions. */
|
|
6
|
+
const PRIVATE_KEYS = ["apiKey", "headers", "baseUrl"];
|
|
7
|
+
/** The catalog is the part of a provider sync owns outright: absent on the
|
|
8
|
+
* source means deleted here, while a field the source never states stays. */
|
|
9
|
+
const CATALOG_KEYS = ["models", "modelOverrides"];
|
|
10
|
+
/** Dropped strings shorter than this ("", "Bearer") collide with legitimate
|
|
11
|
+
* metadata; a credential or an endpoint does not. */
|
|
12
|
+
const SECRET_CHARS = 8;
|
|
13
|
+
const asRecord = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
4
14
|
function record(value, where) {
|
|
5
|
-
|
|
6
|
-
|
|
15
|
+
const plain = asRecord(value);
|
|
16
|
+
if (!plain || ![Object.prototype, null].includes(Object.getPrototypeOf(plain))) {
|
|
7
17
|
throw new Error(`${where} must be an object`);
|
|
8
18
|
}
|
|
9
|
-
return
|
|
19
|
+
return plain;
|
|
10
20
|
}
|
|
11
21
|
function only(value, keys, where) {
|
|
12
22
|
if (Object.keys(value).some((key) => !keys.includes(key))) {
|
|
13
23
|
throw new Error(`${where} contains unsupported fields`);
|
|
14
24
|
}
|
|
15
25
|
}
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/** Every string in a JSON value, so one containment test covers the whole
|
|
27
|
+
* projection however deeply Pi nests a future field. */
|
|
28
|
+
function strings(value, found = []) {
|
|
29
|
+
const nested = asRecord(value);
|
|
30
|
+
if (typeof value === "string")
|
|
31
|
+
found.push(value);
|
|
32
|
+
else if (Array.isArray(value))
|
|
33
|
+
for (const entry of value)
|
|
34
|
+
strings(entry, found);
|
|
35
|
+
else if (nested)
|
|
36
|
+
for (const entry of Object.values(nested))
|
|
37
|
+
strings(entry, found);
|
|
38
|
+
return found;
|
|
39
|
+
}
|
|
40
|
+
/** Deep copy of JSON-only data; the private fields are dropped and handed to
|
|
41
|
+
* `onPrivate` — collected on the way out, refused on the way in. */
|
|
42
|
+
function project(value, where, onPrivate) {
|
|
43
|
+
if (value === null || ["string", "boolean", "number"].includes(typeof value))
|
|
44
|
+
return value;
|
|
45
|
+
if (Array.isArray(value))
|
|
46
|
+
return value.map((entry) => project(entry, where, onPrivate));
|
|
47
|
+
return Object.fromEntries(Object.entries(record(value, where)).flatMap(([key, entry]) => {
|
|
48
|
+
if (PRIVATE_KEYS.includes(key)) {
|
|
49
|
+
onPrivate(key, entry);
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
return [[key, project(entry, `${where} ${key}`, onPrivate)]];
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** The shape both sides must agree on: a provider id that cannot address
|
|
56
|
+
* anything but its own record, and a catalog addressable by model id. */
|
|
57
|
+
function portable(id, value, onPrivate) {
|
|
58
|
+
if (id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(id) || ["constructor", "prototype"].includes(id)) {
|
|
59
|
+
throw new Error("invalid snapshot provider id");
|
|
29
60
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
throw new Error("
|
|
61
|
+
const provider = record(project(value, `provider ${id}`, onPrivate), `provider ${id}`);
|
|
62
|
+
const models = provider.models ?? [];
|
|
63
|
+
if (!Array.isArray(models))
|
|
64
|
+
throw new Error("models must be an array");
|
|
65
|
+
const ids = models.map((model) => record(model, "model").id);
|
|
66
|
+
if (ids.some((modelId) => typeof modelId !== "string" || !modelId.trim() || modelId !== modelId.trim())) {
|
|
67
|
+
throw new Error("model id must be a non-empty trimmed string");
|
|
34
68
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
69
|
+
if (new Set(ids).size !== ids.length)
|
|
70
|
+
throw new Error("duplicate model ids");
|
|
71
|
+
const overrides = provider.modelOverrides ?? {};
|
|
72
|
+
for (const [overrideId, override] of Object.entries(record(overrides, "modelOverrides"))) {
|
|
73
|
+
record(override, `modelOverrides ${overrideId}`);
|
|
39
74
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
75
|
+
return provider;
|
|
76
|
+
}
|
|
77
|
+
/** Project disk metadata before validation, then fail closed if anything the
|
|
78
|
+
* projection removed still appears in it: a deny list cannot foresee the field
|
|
79
|
+
* Pi adds next, but it can refuse to publish a value it just dropped. */
|
|
80
|
+
export function snapshotProviders(providers = {}) {
|
|
81
|
+
const dropped = [];
|
|
82
|
+
const shared = Object.fromEntries(Object.entries(providers).flatMap(([id, provider]) => {
|
|
83
|
+
const value = portable(id, provider, (_key, entry) => {
|
|
84
|
+
dropped.push(...strings(entry).filter((secret) => secret.length >= SECRET_CHARS));
|
|
85
|
+
});
|
|
86
|
+
return Object.keys(value).length ? [[id, value]] : [];
|
|
87
|
+
}));
|
|
88
|
+
const exported = strings(shared);
|
|
89
|
+
if (dropped.some((secret) => exported.some((value) => value.includes(secret)))) {
|
|
90
|
+
throw new Error("models.json repeats a credential or endpoint in a shared field; remove it before sharing");
|
|
48
91
|
}
|
|
49
|
-
return
|
|
92
|
+
return shared;
|
|
50
93
|
}
|
|
51
|
-
/** Strict import boundary:
|
|
94
|
+
/** Strict import boundary: a source that states a private field is refused,
|
|
95
|
+
* never trusted as a mask over the local one. */
|
|
52
96
|
export function normalizeAgentSnapshot(raw) {
|
|
53
97
|
const snapshot = record(raw, "agent snapshot");
|
|
54
98
|
only(snapshot, ["files", "providers"], "agent snapshot");
|
|
@@ -59,65 +103,53 @@ export function normalizeAgentSnapshot(raw) {
|
|
|
59
103
|
throw new Error(`${name} must be a string or null`);
|
|
60
104
|
}
|
|
61
105
|
}
|
|
62
|
-
const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers"))
|
|
63
|
-
|
|
64
|
-
throw new Error(
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const models = provider.models.map(modelDefinition);
|
|
71
|
-
if (new Set(models.map((model) => model.id)).size !== models.length)
|
|
72
|
-
throw new Error("duplicate model ids");
|
|
73
|
-
return [id, { models }];
|
|
74
|
-
}));
|
|
75
|
-
return { files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] }, providers };
|
|
106
|
+
const providers = Object.fromEntries(Object.entries(record(snapshot.providers, "snapshot providers"))
|
|
107
|
+
.map(([id, provider]) => [id, portable(id, provider, (key) => {
|
|
108
|
+
throw new Error(`snapshot provider must not carry ${key}`);
|
|
109
|
+
})]));
|
|
110
|
+
return {
|
|
111
|
+
files: { "SYSTEM.md": files["SYSTEM.md"], "AGENTS.md": files["AGENTS.md"] },
|
|
112
|
+
providers,
|
|
113
|
+
};
|
|
76
114
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const models = provider.models.map((value) => {
|
|
85
|
-
const source = record(value, "models.json model");
|
|
86
|
-
const model = Object.fromEntries(MODEL_KEYS.flatMap((key) => source[key] === undefined ? [] : [[key, source[key]]]));
|
|
87
|
-
if (source.cost !== undefined) {
|
|
88
|
-
const cost = record(source.cost, "models.json model cost");
|
|
89
|
-
model.cost = Object.fromEntries(COST_KEYS.flatMap((key) => cost[key] === undefined ? [] : [[key, cost[key]]]));
|
|
90
|
-
}
|
|
91
|
-
return modelDefinition(model);
|
|
92
|
-
});
|
|
93
|
-
return [[id, { models }]];
|
|
94
|
-
}));
|
|
115
|
+
/** A model or override arrives whole; only the local private fields survive it. */
|
|
116
|
+
function withLocalPrivate(incoming, local) {
|
|
117
|
+
const merged = { ...incoming };
|
|
118
|
+
for (const key of PRIVATE_KEYS)
|
|
119
|
+
if (local && Object.hasOwn(local, key))
|
|
120
|
+
merged[key] = local[key];
|
|
121
|
+
return merged;
|
|
95
122
|
}
|
|
96
|
-
|
|
123
|
+
const modelsById = (models) => new Map((Array.isArray(models) ? models : []).flatMap((model) => {
|
|
124
|
+
const value = asRecord(model);
|
|
125
|
+
return value ? [[value.id, value]] : [];
|
|
126
|
+
}));
|
|
127
|
+
/** The source sets what it states and replaces the catalog; credentials and
|
|
128
|
+
* endpoints stay local, matched by provider, model id and override id. */
|
|
97
129
|
export function mergeSnapshotProviders(local = {}, incoming) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
if (!Object.hasOwn(incoming, id)) {
|
|
101
|
-
delete provider.models;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
for (const [id, { models }] of Object.entries(incoming)) {
|
|
105
|
-
if (!Object.hasOwn(local, id)) {
|
|
130
|
+
for (const id of Object.keys(incoming)) {
|
|
131
|
+
if (!Object.hasOwn(local, id))
|
|
106
132
|
throw new Error(`provider ${id} must be configured locally before importing models`);
|
|
107
|
-
}
|
|
108
|
-
const provider = providers[id];
|
|
109
|
-
if (provider.models !== undefined && !Array.isArray(provider.models))
|
|
110
|
-
throw new Error("models.json models must be an array");
|
|
111
|
-
const existing = new Map((provider.models ?? []).map((value) => {
|
|
112
|
-
const model = record(value, "models.json model");
|
|
113
|
-
return [model.id, model];
|
|
114
|
-
}));
|
|
115
|
-
provider.models = models.map((model) => {
|
|
116
|
-
const retained = { ...existing.get(model.id) };
|
|
117
|
-
for (const key of MODEL_KEYS)
|
|
118
|
-
delete retained[key];
|
|
119
|
-
return { ...retained, ...model };
|
|
120
|
-
});
|
|
121
133
|
}
|
|
122
|
-
return
|
|
134
|
+
return Object.fromEntries(Object.entries(local).map(([id, provider]) => {
|
|
135
|
+
const current = record(provider, "models.json provider");
|
|
136
|
+
const source = incoming[id];
|
|
137
|
+
if (!source)
|
|
138
|
+
return [id, Object.fromEntries(Object.entries(current).filter(([key]) => !CATALOG_KEYS.includes(key)))];
|
|
139
|
+
// The catalog shapes are the ones normalizeAgentSnapshot already proved.
|
|
140
|
+
const merged = { ...current, ...structuredClone(source) };
|
|
141
|
+
for (const key of CATALOG_KEYS)
|
|
142
|
+
if (!Object.hasOwn(source, key))
|
|
143
|
+
delete merged[key];
|
|
144
|
+
if (merged.models !== undefined) {
|
|
145
|
+
const previous = modelsById(current.models);
|
|
146
|
+
merged.models = merged.models.map((model) => withLocalPrivate(model, previous.get(model.id)));
|
|
147
|
+
}
|
|
148
|
+
if (merged.modelOverrides !== undefined) {
|
|
149
|
+
const previous = asRecord(current.modelOverrides) ?? {};
|
|
150
|
+
merged.modelOverrides = Object.fromEntries(Object.entries(merged.modelOverrides)
|
|
151
|
+
.map(([key, override]) => [key, withLocalPrivate(override, asRecord(previous[key]))]));
|
|
152
|
+
}
|
|
153
|
+
return [id, merged];
|
|
154
|
+
}));
|
|
123
155
|
}
|
package/dist/boards/boards.js
CHANGED
|
@@ -124,14 +124,18 @@ export async function listBoards(dir) {
|
|
|
124
124
|
}
|
|
125
125
|
// One board's manifest says nothing about the next one's, so the scan waits
|
|
126
126
|
// once for all of them rather than once per board.
|
|
127
|
-
const boards = await Promise.all(entries.
|
|
127
|
+
const boards = await Promise.all(entries.map(async (slug) => {
|
|
128
128
|
const manifest = await readManifest(dir, slug);
|
|
129
129
|
if (!manifest)
|
|
130
130
|
return null;
|
|
131
131
|
const { title, description, sessions, public: isPublic, token } = manifest;
|
|
132
132
|
return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
|
|
133
133
|
}));
|
|
134
|
-
|
|
134
|
+
// Freshest first: the board someone just wrote is the one they came to see.
|
|
135
|
+
// Slug breaks ties so equal mtimes still list in a stable order.
|
|
136
|
+
return boards
|
|
137
|
+
.filter((board) => board !== null)
|
|
138
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug));
|
|
135
139
|
}
|
|
136
140
|
/** Containment, not normalization: the resolved realpath must sit inside the
|
|
137
141
|
* board's own site dir or nothing is served. */
|
package/dist/db.js
CHANGED
|
@@ -316,6 +316,13 @@ const MIGRATIONS = [
|
|
|
316
316
|
CREATE INDEX task_runs_visible_time ON task_runs(queued_at DESC, id DESC)
|
|
317
317
|
WHERE NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0);
|
|
318
318
|
`,
|
|
319
|
+
// 18 — opening a session lists every run it delegated, not the last hour's.
|
|
320
|
+
`
|
|
321
|
+
-- The run cards are the messages a session sent, so the transcript wants
|
|
322
|
+
-- all of them: the query is by the delegating session, which lived only in
|
|
323
|
+
-- the JSON — without this every session open was a full scan of task_runs.
|
|
324
|
+
CREATE INDEX task_runs_invoked_by ON task_runs(json_extract(json, '$.invokedBySessionId'), queued_at DESC);
|
|
325
|
+
`,
|
|
319
326
|
];
|
|
320
327
|
/**
|
|
321
328
|
* Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
|
|
@@ -361,9 +368,11 @@ let shared;
|
|
|
361
368
|
* defaults to it; a test passes `openDb(":memory:")` instead.
|
|
362
369
|
*/
|
|
363
370
|
export const pierDb = () => (shared ??= openDb(PIER_DB));
|
|
364
|
-
/** A release-level restore point, taken
|
|
365
|
-
*
|
|
366
|
-
*
|
|
371
|
+
/** A release-level restore point, taken for every release even when it has no
|
|
372
|
+
* schema migration. The previous complete copies stay put if writing this one
|
|
373
|
+
* fails, and the service may be running while it is written: `copyDatabase`
|
|
374
|
+
* reads through a read-only connection, so what it writes is one consistent
|
|
375
|
+
* snapshot of a live database rather than a torn `cp`.
|
|
367
376
|
*
|
|
368
377
|
* `version` is the Pier that produced this database, not the one being
|
|
369
378
|
* installed: the updater runs this from the tree it is about to replace, and
|
package/dist/main.js
CHANGED
|
@@ -42,14 +42,13 @@ import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config
|
|
|
42
42
|
import { PushStore, registerPushRoutes } from "./web/push.js";
|
|
43
43
|
import { SessionStateStore } from "./web/session-state.js";
|
|
44
44
|
import { createServer } from "./web/server.js";
|
|
45
|
-
import { attachTerminal } from "./web/terminal.js";
|
|
46
45
|
const log = logger("pier");
|
|
47
46
|
// Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
|
|
48
47
|
// everything Pi derives from its agent dir (auth.json, models.json, sessions,
|
|
49
48
|
// bin) lands under PIER_HOME instead of ~/.pi.
|
|
50
49
|
//
|
|
51
50
|
// An operator override wins — but only a human's. Everything Pier spawns (the
|
|
52
|
-
//
|
|
51
|
+
// agent's own shell, task sessions) inherits this variable, so a second
|
|
53
52
|
// Pier started from inside the first with its own PIER_HOME would adopt the
|
|
54
53
|
// first one's agent dir and write its sessions, SYSTEM.md and models.json
|
|
55
54
|
// there: two instances sharing a directory neither was told to share, and
|
|
@@ -58,8 +57,8 @@ const log = logger("pier");
|
|
|
58
57
|
// again from this instance's own PIER_HOME.
|
|
59
58
|
process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
|
|
60
59
|
process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
|
|
61
|
-
// Ahead of everything Pier spawns — sessions
|
|
62
|
-
//
|
|
60
|
+
// Ahead of everything Pier spawns — sessions and tasks all inherit this
|
|
61
|
+
// process's env. A tool switched on in the Console is Pier's
|
|
63
62
|
// copy at Pier's version, so it goes first, not last.
|
|
64
63
|
prependPath(process.env);
|
|
65
64
|
// First, and explicitly: every store below shares this one connection, and a
|
|
@@ -205,8 +204,9 @@ await configurationSync.reconcile();
|
|
|
205
204
|
tasks.start();
|
|
206
205
|
readyForConfigReload = true;
|
|
207
206
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
208
|
-
// Replacing Pier is systemd's job, not this process's: the oneshot unit
|
|
209
|
-
//
|
|
207
|
+
// Replacing Pier is systemd's job, not this process's: the oneshot unit
|
|
208
|
+
// snapshots the database, installs, then stops the service and starts it again
|
|
209
|
+
// on the new version — in that order, so only the last two are downtime. Without
|
|
210
210
|
// that unit there is nothing to hand the work to, and the Console says so
|
|
211
211
|
// instead of offering a button that cannot work.
|
|
212
212
|
const updates = new UpdateCheck();
|
|
@@ -247,8 +247,14 @@ const takeWorkAgain = (why) => {
|
|
|
247
247
|
};
|
|
248
248
|
/** How long the handover has to actually stop us. `systemctl start --no-block`
|
|
249
249
|
* returns when the job is *queued*, so "started" is not proof of anything;
|
|
250
|
-
* the real outcome is a SIGTERM
|
|
251
|
-
|
|
250
|
+
* the real outcome is a SIGTERM — but only after the updater has snapshotted
|
|
251
|
+
* the database and installed the new version, which is a registry download on
|
|
252
|
+
* someone else's network — ten seconds on a good day, minutes on a bad one.
|
|
253
|
+
* So this waits far longer than the stop itself needs, because reopening the
|
|
254
|
+
* gate mid-install would take work we are about to be SIGTERM'd out of, and
|
|
255
|
+
* still short enough that a handover which never happens does not refuse
|
|
256
|
+
* messages all afternoon. */
|
|
257
|
+
const HANDOVER_GRACE_MS = 5 * 60_000;
|
|
252
258
|
const handOverToUpdater = async () => {
|
|
253
259
|
// One handover at a time, and never on top of a restart: the Console button,
|
|
254
260
|
// the auto-update tick and SIGUSR2 would otherwise drain the same Pier
|
|
@@ -389,11 +395,6 @@ const server = serve({ fetch: app.fetch, port, hostname }, () => {
|
|
|
389
395
|
log.info(`agent dir ${process.env.PI_CODING_AGENT_DIR} (PI_CODING_AGENT_DIR)`);
|
|
390
396
|
}
|
|
391
397
|
});
|
|
392
|
-
// The one WebSocket surface (see web/terminal.ts); `serve` above builds a
|
|
393
|
-
// plain node:http server, which is the only shape with an upgrade event.
|
|
394
|
-
const terminals = attachTerminal(server, auth, {
|
|
395
|
-
initCommand: () => settings.get().terminalInitCommand,
|
|
396
|
-
});
|
|
397
398
|
// A crash and a clean stop must be distinguishable after the fact, and both
|
|
398
399
|
// left nothing behind before this.
|
|
399
400
|
process.on("uncaughtException", (err) => {
|
|
@@ -416,7 +417,6 @@ const shutdown = (stopTasks = true) => {
|
|
|
416
417
|
// `systemctl restart` into a 90-second wait for SIGKILL.
|
|
417
418
|
setTimeout(() => process.exit(0), 3000).unref();
|
|
418
419
|
stopEviction();
|
|
419
|
-
terminals.close(); // no shell outlives the workbench
|
|
420
420
|
// The drain path leaves task runs alone: aborting them here would record
|
|
421
421
|
// them cancelled and race their callbacks against dying channels, when the
|
|
422
422
|
// boot-time interrupted marking is the recovery that was promised.
|
package/dist/service.js
CHANGED
|
@@ -104,8 +104,14 @@ function legacyOptions(home) {
|
|
|
104
104
|
...(pierHome ? { pierHome } : {}),
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
/** A separate cgroup
|
|
108
|
-
*
|
|
107
|
+
/** A separate cgroup snapshots the database, updates the exact npm
|
|
108
|
+
* installation recorded at install time, and only then restarts Pier.
|
|
109
|
+
*
|
|
110
|
+
* Both slow steps run while the service is still up, so the downtime is one
|
|
111
|
+
* stop and one start rather than the ~10s an install takes. The snapshot is
|
|
112
|
+
* consistent on a live database — `VACUUM INTO` off a read-only connection
|
|
113
|
+
* (db.ts) — and it still runs from the tree npm is about to replace, so the
|
|
114
|
+
* copy carries the version whose schema it pairs with. */
|
|
109
115
|
export function renderUpdateUnit(options) {
|
|
110
116
|
const { execPath, npmPath, entry, pierHome, shellPath } = options;
|
|
111
117
|
const cli = join(dirname(entry), "cli.js");
|
|
@@ -120,12 +126,16 @@ Type=oneshot
|
|
|
120
126
|
# resolves node from PATH, and systemd's minimal PATH has no fnm/nvm node —
|
|
121
127
|
# the install then dies with "node: not found" with the tree half written.
|
|
122
128
|
${environment("PATH", pathEnv(execPath, shellPath))}
|
|
123
|
-
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart
|
|
124
|
-
ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
129
|
+
${pierHome ? `${environment("PIER_HOME", pierHome)}\n` : ""}ExecStart=${quote(execPath, true)} ${quote(cli, true)} backup
|
|
125
130
|
# npm runs under the recorded node: its shebang needs a node on PATH too.
|
|
126
131
|
ExecStart=${quote(execPath, true)} ${quote(npmPath, true)} install -g @timqi/pier@latest
|
|
127
|
-
#
|
|
128
|
-
#
|
|
132
|
+
# Last, because everything above it is downtime the running Pier does not owe
|
|
133
|
+
# anyone. A failed backup or install never gets here: the service stays up on
|
|
134
|
+
# the code it already loaded.
|
|
135
|
+
ExecStart=systemctl --user stop ${UNIT_NAME}
|
|
136
|
+
# ExecStopPost runs on success and failure. After a failed step above, Pier was
|
|
137
|
+
# never stopped and this is a no-op; it is here for the window that is still
|
|
138
|
+
# fatal — the unit dying between that stop and the start of the new version.
|
|
129
139
|
ExecStopPost=systemctl --user start ${UNIT_NAME}
|
|
130
140
|
`;
|
|
131
141
|
}
|
|
@@ -300,7 +310,7 @@ export function startUpdate(options) {
|
|
|
300
310
|
if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
|
|
301
311
|
return "failed";
|
|
302
312
|
say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
|
|
303
|
-
say(`Pier
|
|
313
|
+
say(`Pier snapshots the database into db/backups/ and installs while still up, then stops and starts on the new version.`);
|
|
304
314
|
return "started";
|
|
305
315
|
}
|
|
306
316
|
export function uninstall(home = homedir(), say = console.log, exec) {
|
package/dist/settings.js
CHANGED
|
@@ -71,26 +71,6 @@ export function normalizeModelMenu(raw) {
|
|
|
71
71
|
}
|
|
72
72
|
return menu;
|
|
73
73
|
}
|
|
74
|
-
/**
|
|
75
|
-
* One line, typed into a shell. Same reject-don't-repair contract: a command
|
|
76
|
-
* silently truncated at a newline would run half of what the operator wrote,
|
|
77
|
-
* in every project shell, with no sign of what was dropped.
|
|
78
|
-
*/
|
|
79
|
-
export function normalizeTerminalInitCommand(raw) {
|
|
80
|
-
if (typeof raw !== "string")
|
|
81
|
-
return null;
|
|
82
|
-
const text = raw.trim();
|
|
83
|
-
if (!text)
|
|
84
|
-
return "";
|
|
85
|
-
if (text.length > 500)
|
|
86
|
-
return null;
|
|
87
|
-
// A tty reads these as keys, not text: an embedded newline is a second
|
|
88
|
-
// command nobody would see in the field it was typed in.
|
|
89
|
-
for (const ch of text)
|
|
90
|
-
if (ch < " " || ch === "\u007f")
|
|
91
|
-
return null;
|
|
92
|
-
return text;
|
|
93
|
-
}
|
|
94
74
|
/**
|
|
95
75
|
* Shape only — an unknown name is not an error here. This file must not know
|
|
96
76
|
* what Pier bundles (that catalog is code, and importing it would drag the Pi
|
|
@@ -131,7 +111,6 @@ export class SettingsStore {
|
|
|
131
111
|
publicUrl: this.#value("publicUrl") ?? "",
|
|
132
112
|
modelMenu: this.#json("modelMenu", normalizeModelMenu, "a valid menu") ?? [],
|
|
133
113
|
autoUpdate: this.#value("autoUpdate") === "1",
|
|
134
|
-
terminalInitCommand: this.#value("terminalInitCommand") ?? "",
|
|
135
114
|
extensions: this.#json("extensions", normalizeExtensions, "a list of names") ?? [],
|
|
136
115
|
tools: this.#json("tools", normalizeTools, "a list of names") ?? [],
|
|
137
116
|
customTools: this.#json("customTools", normalizeCustomTools, "a list of {name, spec}") ?? [],
|
|
@@ -170,11 +149,6 @@ export class SettingsStore {
|
|
|
170
149
|
this.#set("modelMenu", JSON.stringify(menu));
|
|
171
150
|
return this.get();
|
|
172
151
|
}
|
|
173
|
-
/** Same contract again: hand this `normalizeTerminalInitCommand`'s output. */
|
|
174
|
-
setTerminalInitCommand(command) {
|
|
175
|
-
this.#set("terminalInitCommand", command);
|
|
176
|
-
return this.get();
|
|
177
|
-
}
|
|
178
152
|
setAutoUpdate(on) {
|
|
179
153
|
this.#set("autoUpdate", on ? "1" : "0");
|
|
180
154
|
return this.get();
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -53,7 +53,7 @@ export class TaskCallbacks {
|
|
|
53
53
|
// one delivery, and the first one's name and model as the card's
|
|
54
54
|
// caption would attribute every other result to it. The text names
|
|
55
55
|
// each run; the card says nothing rather than something false.
|
|
56
|
-
...(runs.length === 1 ? { source: runSource(runs[0]) } : {}),
|
|
56
|
+
...(runs.length === 1 ? { source: runSource(runs[0]), state: runs[0].state } : {}),
|
|
57
57
|
},
|
|
58
58
|
}),
|
|
59
59
|
describe: (run) => `the result of "${run.context.definition.name}"`,
|
|
@@ -247,6 +247,10 @@ export class TaskDefinitions {
|
|
|
247
247
|
return this.router.stateOf(sessionId) !== undefined ||
|
|
248
248
|
(await this.factory.find(sessionId)) !== undefined;
|
|
249
249
|
}
|
|
250
|
+
/** A caller's own directory — what a relative or omitted cwd resolves against. */
|
|
251
|
+
async sessionCwd(sessionId) {
|
|
252
|
+
return (await this.factory.find(sessionId))?.cwd;
|
|
253
|
+
}
|
|
250
254
|
/** The guard `ownerOf` exists for, on the three ways a definition changes. */
|
|
251
255
|
assertOwner(task, by, what) {
|
|
252
256
|
const owner = ownerOf(task);
|
package/dist/tasks/groups.js
CHANGED
|
@@ -27,7 +27,8 @@ export class TaskGroups {
|
|
|
27
27
|
save: (group) => { this.store.saveGroup(group); },
|
|
28
28
|
changed,
|
|
29
29
|
// A group names itself in the origin where a run names its run id, so the
|
|
30
|
-
// engine's transcript proof works unchanged
|
|
30
|
+
// engine's transcript proof works unchanged; the console's run route
|
|
31
|
+
// resolves that id to the group's members (web/ui/task-runs.ts).
|
|
31
32
|
input: (groups) => ({
|
|
32
33
|
text: this.text(groups[0]),
|
|
33
34
|
origin: { kind: "task-callback", taskId: groups[0].id, runId: groups[0].id, sourceSessionId: null },
|
package/dist/tasks/service.js
CHANGED
|
@@ -18,6 +18,15 @@ import { TaskStore } from "./store.js";
|
|
|
18
18
|
import { handleTaskTool } from "./tool.js";
|
|
19
19
|
import { isTerminal } from "./types.js";
|
|
20
20
|
const log = logger("tasks");
|
|
21
|
+
/** The text a run was given, as the operator wrote it: a resume's follow-up,
|
|
22
|
+
* else the action's own prompt or script. Not `renderedPrompt` — that is the
|
|
23
|
+
* preamble and the input wrapper, which the delegating session did not send. */
|
|
24
|
+
const runPrompt = (run) => {
|
|
25
|
+
if (run.context.resumePrompt)
|
|
26
|
+
return run.context.resumePrompt;
|
|
27
|
+
const action = run.context.definition.action;
|
|
28
|
+
return action.type === "agent" ? action.prompt : action.type === "bash" ? action.script : null;
|
|
29
|
+
};
|
|
21
30
|
export class TaskService {
|
|
22
31
|
store;
|
|
23
32
|
factory;
|
|
@@ -195,11 +204,11 @@ export class TaskService {
|
|
|
195
204
|
recentMessages(since) {
|
|
196
205
|
return this.messages.recent(since);
|
|
197
206
|
}
|
|
207
|
+
/** Every run this session delegated: the card is the message that started
|
|
208
|
+
* it, and a message does not leave the transcript because the run ended. */
|
|
198
209
|
backgroundRuns(sessionId) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
.filter((run) => run.background && (!isTerminal(run.state) || run.queuedAt >= cutoff))
|
|
202
|
-
.slice(0, 20)
|
|
210
|
+
return this.store.listRunsForSession(sessionId, 200)
|
|
211
|
+
.filter((run) => run.background)
|
|
203
212
|
.reverse()
|
|
204
213
|
.map((run) => this.backgroundRun(run));
|
|
205
214
|
}
|
|
@@ -364,6 +373,7 @@ export class TaskService {
|
|
|
364
373
|
targetSessionId: run.targetSessionId,
|
|
365
374
|
sessionMode: run.sessionMode,
|
|
366
375
|
depth: run.depth,
|
|
376
|
+
prompt: runPrompt(run),
|
|
367
377
|
queuedAt: run.queuedAt,
|
|
368
378
|
startedAt: run.startedAt,
|
|
369
379
|
finishedAt: run.finishedAt,
|