@timqi/pier 0.0.19 → 0.0.21
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/dist/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +10 -1
- package/dist/agent/pi.js +50 -28
- package/dist/config-sync-fetch.js +84 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +10 -3
- package/dist/core/reply.js +12 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +6 -0
- package/dist/main.js +31 -1
- package/dist/tasks/agent.js +11 -22
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +25 -16
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +57 -14
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +9 -4
- package/dist/tasks/store.js +45 -0
- package/dist/tasks/tool.js +8 -4
- package/dist/web/config-sync.js +91 -0
- package/dist/web/public/assets/activity-Cw0LRVNI.js +5 -0
- 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-DEMQ51m1.js → boards-DMVbV84c.js} +1 -1
- 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/{code-DjWvqL94.js → code-C86xKk2k.js} +1 -1
- package/dist/web/public/assets/{explorer-BTGQ6Syc.js → explorer-DTvsy5Rs.js} +1 -1
- 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-D8CLFi_l.js → ghostty-web-CvSZragH.js} +1 -1
- 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 +79 -0
- 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-yNocr797.css → index-DuZ2vERd.css} +1 -1
- 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 +1 -0
- 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 +6 -0
- 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 +3 -0
- 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 +4 -0
- 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-zLyOvGto.js → terminal-DL_0yEy7.js} +2 -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/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/server.js +65 -60
- package/package.json +2 -2
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/public/assets/activity-BsoWK6vt.js +0 -5
- package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js +0 -78
- package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
- package/dist/web/public/assets/index-yNocr797.css.br +0 -0
- package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js +0 -6
- package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js +0 -4
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// One serialized configuration subscriber, shared by Settings and the hourly
|
|
2
|
+
// task. Only a successfully applied response advances its persistent ETag.
|
|
3
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { configSourceUrl, CONFIG_SYNC_BYTES, downloadConfig } from "./config-sync-fetch.js";
|
|
5
|
+
import { transact } from "./db.js";
|
|
6
|
+
import { logger } from "./log.js";
|
|
7
|
+
import { normalizeModelMenu } from "./settings.js";
|
|
8
|
+
const log = logger("config-sync");
|
|
9
|
+
const KEY = "configSync";
|
|
10
|
+
// Object order is not a change; array order (model menu priority) is.
|
|
11
|
+
export function configJson(value) {
|
|
12
|
+
return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
13
|
+
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
|
|
14
|
+
}
|
|
15
|
+
const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
|
|
16
|
+
export class ConfigSync {
|
|
17
|
+
deps;
|
|
18
|
+
#state;
|
|
19
|
+
#queue = Promise.resolve();
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
this.deps = deps;
|
|
22
|
+
const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
|
|
23
|
+
this.#state = row ? JSON.parse(row.value) : {
|
|
24
|
+
instanceId: randomUUID(), token: null, url: "", enabled: false, etag: null,
|
|
25
|
+
appliedHash: null, lastChecked: null, lastApplied: null, error: null, needsReload: false,
|
|
26
|
+
};
|
|
27
|
+
if (!row)
|
|
28
|
+
this.#save();
|
|
29
|
+
}
|
|
30
|
+
#save(patch = {}) {
|
|
31
|
+
const next = { ...this.#state, ...patch };
|
|
32
|
+
this.deps.db.prepare("INSERT INTO settings(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
33
|
+
.run(KEY, JSON.stringify(next));
|
|
34
|
+
this.#state = next;
|
|
35
|
+
}
|
|
36
|
+
#exclusive(work) {
|
|
37
|
+
const result = this.#queue.then(work);
|
|
38
|
+
this.#queue = result.then(() => undefined, () => undefined);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
status() {
|
|
42
|
+
const state = this.#state;
|
|
43
|
+
return {
|
|
44
|
+
publishedPath: state.token ? `/config-sync/${state.token}` : null,
|
|
45
|
+
sourceUrl: state.url, enabled: state.enabled,
|
|
46
|
+
lastChecked: state.lastChecked, lastApplied: state.lastApplied,
|
|
47
|
+
error: state.error, needsReload: state.needsReload,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async #local() {
|
|
51
|
+
return { agent: await this.deps.config.exportSnapshot(), modelMenu: this.deps.settings.get().modelMenu };
|
|
52
|
+
}
|
|
53
|
+
publish() {
|
|
54
|
+
return this.#exclusive(async () => {
|
|
55
|
+
if (this.#state.enabled)
|
|
56
|
+
throw new Error("Pause the configuration subscription before publishing a link");
|
|
57
|
+
await this.#local();
|
|
58
|
+
this.#save({ token: randomBytes(32).toString("hex") });
|
|
59
|
+
return this.status();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
revoke() {
|
|
63
|
+
return this.#exclusive(async () => {
|
|
64
|
+
this.#save({ token: null });
|
|
65
|
+
return this.status();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async published(token) {
|
|
69
|
+
const expected = this.#state.token;
|
|
70
|
+
if (!expected || !/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(token), Buffer.from(expected)))
|
|
71
|
+
return null;
|
|
72
|
+
const local = await this.#local();
|
|
73
|
+
if (this.#state.token !== expected)
|
|
74
|
+
return null;
|
|
75
|
+
const document = { schemaVersion: 1, instanceId: this.#state.instanceId, ...local };
|
|
76
|
+
const body = configJson(document);
|
|
77
|
+
if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
|
|
78
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
79
|
+
return { body, etag: `"${hash(document)}"` };
|
|
80
|
+
}
|
|
81
|
+
#parse(raw) {
|
|
82
|
+
if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
|
|
83
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
84
|
+
let value;
|
|
85
|
+
try {
|
|
86
|
+
value = JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error("Source returned malformed JSON");
|
|
90
|
+
}
|
|
91
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
92
|
+
throw new Error("Invalid configuration document");
|
|
93
|
+
const object = value;
|
|
94
|
+
if (object.schemaVersion !== 1)
|
|
95
|
+
throw new Error("Unsupported configuration schema version");
|
|
96
|
+
if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
|
|
97
|
+
throw new Error("Unexpected configuration fields");
|
|
98
|
+
}
|
|
99
|
+
if (typeof object.instanceId !== "string" || !object.instanceId || object.instanceId.length > 100)
|
|
100
|
+
throw new Error("Invalid source instance ID");
|
|
101
|
+
if (object.instanceId === this.#state.instanceId)
|
|
102
|
+
throw new Error("An instance cannot subscribe to itself");
|
|
103
|
+
const modelMenu = normalizeModelMenu(object.modelMenu);
|
|
104
|
+
if (!modelMenu)
|
|
105
|
+
throw new Error("Invalid model menu");
|
|
106
|
+
return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
|
|
107
|
+
}
|
|
108
|
+
subscribe(url) {
|
|
109
|
+
return this.#exclusive(async () => {
|
|
110
|
+
if (this.#state.token)
|
|
111
|
+
throw new Error("Revoke the sharing link before enabling a configuration subscription");
|
|
112
|
+
return this.#sync(url);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
pause() {
|
|
116
|
+
return this.#exclusive(async () => {
|
|
117
|
+
this.#save({ enabled: false });
|
|
118
|
+
return this.status();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
sync(signal) {
|
|
122
|
+
return this.#exclusive(() => this.#state.enabled
|
|
123
|
+
? this.#sync(this.#state.url, signal)
|
|
124
|
+
: Promise.resolve("Configuration subscription is paused"));
|
|
125
|
+
}
|
|
126
|
+
async #sync(rawUrl, signal) {
|
|
127
|
+
try {
|
|
128
|
+
const url = configSourceUrl(rawUrl).href;
|
|
129
|
+
const local = await this.#local();
|
|
130
|
+
const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
|
|
131
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
132
|
+
const response = await (this.deps.download ?? downloadConfig)(url, etag, signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
if (response.status === 304 && !etag)
|
|
135
|
+
throw new Error("Source returned 304 without a matching local configuration");
|
|
136
|
+
const next = response.status === 304 ? local : this.#parse(response.body);
|
|
137
|
+
let changed = false;
|
|
138
|
+
const previous = structuredClone(this.#state);
|
|
139
|
+
const commit = (filesChanged) => transact(this.deps.db, () => {
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
const menuChanged = configJson(next.modelMenu) !== configJson(this.deps.settings.get().modelMenu);
|
|
142
|
+
changed = filesChanged || menuChanged;
|
|
143
|
+
if (menuChanged)
|
|
144
|
+
this.deps.settings.setModelMenu(next.modelMenu);
|
|
145
|
+
this.#save({
|
|
146
|
+
url, enabled: true, etag: response.status === 304 ? etag : response.etag,
|
|
147
|
+
appliedHash: hash(next), lastChecked: Date.now(),
|
|
148
|
+
lastApplied: changed ? Date.now() : previous.lastApplied,
|
|
149
|
+
needsReload: changed || previous.needsReload, error: null,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
// Reconcile under the file lock even on 304: local edits may have
|
|
154
|
+
// completed while the conditional download was in flight.
|
|
155
|
+
await this.deps.config.applySnapshot(next.agent, commit);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.#state = previous;
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (this.#state.needsReload) {
|
|
162
|
+
try {
|
|
163
|
+
await this.deps.reload();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error("Configuration saved, but reload failed; retry synchronization");
|
|
167
|
+
}
|
|
168
|
+
this.#save({ needsReload: false });
|
|
169
|
+
}
|
|
170
|
+
return changed ? "Configuration applied; reload completed" : `Configuration unchanged (${response.status})`;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const error = err instanceof Error ? err.message : "Configuration sync failed";
|
|
174
|
+
log.error(error);
|
|
175
|
+
this.#save({ lastChecked: Date.now(), error });
|
|
176
|
+
throw new Error(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/core/hub.js
CHANGED
|
@@ -23,7 +23,7 @@ export class EventHub {
|
|
|
23
23
|
bus(sessionId) {
|
|
24
24
|
let b = this.buses.get(sessionId);
|
|
25
25
|
if (!b) {
|
|
26
|
-
b = { seq: 0, buffer: [], subscribers: new Set() };
|
|
26
|
+
b = { seq: 0, replayFloor: 0, buffer: [], subscribers: new Set() };
|
|
27
27
|
this.buses.set(sessionId, b);
|
|
28
28
|
}
|
|
29
29
|
return b;
|
|
@@ -46,7 +46,7 @@ export class EventHub {
|
|
|
46
46
|
if (payload.type !== "text-delta") {
|
|
47
47
|
b.buffer.push(event);
|
|
48
48
|
if (b.buffer.length > RING_SIZE)
|
|
49
|
-
b.buffer.shift();
|
|
49
|
+
b.replayFloor = b.buffer.shift().seq;
|
|
50
50
|
}
|
|
51
51
|
fanOut(b.subscribers, event);
|
|
52
52
|
}
|
|
@@ -60,6 +60,11 @@ export class EventHub {
|
|
|
60
60
|
replay(sessionId, afterSeq) {
|
|
61
61
|
return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
|
|
62
62
|
}
|
|
63
|
+
/** Whether replay covers this cursor; live-only text gaps are intentional. */
|
|
64
|
+
covers(sessionId, afterSeq) {
|
|
65
|
+
const b = this.bus(sessionId);
|
|
66
|
+
return Number.isSafeInteger(afterSeq) && afterSeq >= b.replayFloor && afterSeq <= b.seq;
|
|
67
|
+
}
|
|
63
68
|
emitWorkspace(event) {
|
|
64
69
|
fanOut(this.workspace, event);
|
|
65
70
|
}
|
|
@@ -88,7 +93,9 @@ export class EventHub {
|
|
|
88
93
|
if (this.hasSubscribers(sessionId))
|
|
89
94
|
return;
|
|
90
95
|
const b = this.buses.get(sessionId);
|
|
91
|
-
if (b)
|
|
96
|
+
if (b) {
|
|
97
|
+
b.replayFloor = b.buffer.at(-1)?.seq ?? b.replayFloor;
|
|
92
98
|
b.buffer = [];
|
|
99
|
+
}
|
|
93
100
|
}
|
|
94
101
|
}
|
package/dist/core/reply.js
CHANGED
|
@@ -40,14 +40,20 @@ new day — so the last one still applies; a gap alone shows as time only, like
|
|
|
40
40
|
\`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
|
|
41
41
|
`;
|
|
42
42
|
/**
|
|
43
|
-
* The contract above plus the
|
|
43
|
+
* The contract above plus the facts about *this* deployment that an agent
|
|
44
44
|
* cannot work out for itself.
|
|
45
45
|
*
|
|
46
|
-
* Told, not discovered. Pier knows
|
|
46
|
+
* Told, not discovered. Pier knows them — the home is its own startup argument
|
|
47
47
|
* and the address is a file it wrote — while an agent can only guess at a
|
|
48
48
|
* path and shell out to look. The guess is wrong on every instance that moves
|
|
49
49
|
* `PIER_HOME`, and it fails as "nothing is configured": indistinguishable from
|
|
50
50
|
* the truth, and reported to the user as such. Which is what happened.
|
|
51
|
+
*
|
|
52
|
+
* The editing line is the same shape of fact, learned the same way: GPT models
|
|
53
|
+
* carry `apply_patch` from their own post-training, and not finding it in the
|
|
54
|
+
* tool list they go looking in the shell — one failed call, then a filesystem
|
|
55
|
+
* hunt, and in one session a stale Codex binary that worked, which put every
|
|
56
|
+
* later edit outside Pi's tooling.
|
|
51
57
|
*/
|
|
52
58
|
export function surfacePrompt(instance) {
|
|
53
59
|
const reach = instance.publicUrl
|
|
@@ -60,6 +66,10 @@ export function surfacePrompt(instance) {
|
|
|
60
66
|
## This Pier instance
|
|
61
67
|
|
|
62
68
|
Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
|
|
69
|
+
|
|
70
|
+
Editing: files change through the \`edit\` tool (exact text replacement) or
|
|
71
|
+
\`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
|
|
72
|
+
so do not call one or go looking for one in the shell.
|
|
63
73
|
`;
|
|
64
74
|
}
|
|
65
75
|
/**
|
package/dist/core/router.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Conversation → session routing plus event wiring. In-memory on purpose:
|
|
2
2
|
// the durable chat → session map lives in channels/conversations.ts.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { logger } from "../log.js";
|
|
4
5
|
import { EventHub } from "./hub.js";
|
|
5
6
|
import { SenderPrefix, withPrefix } from "./identity.js";
|
|
@@ -22,6 +23,13 @@ function keyOf(key) {
|
|
|
22
23
|
* `ensure`, and which of the two a session records costs nothing but the
|
|
23
24
|
* answer to "what is it answering". */
|
|
24
25
|
const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
|
|
26
|
+
export class QueueOperationError extends Error {
|
|
27
|
+
reason;
|
|
28
|
+
constructor(reason, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
25
33
|
export class Router {
|
|
26
34
|
hub;
|
|
27
35
|
resolve;
|
|
@@ -112,6 +120,8 @@ export class Router {
|
|
|
112
120
|
for (const [id, attached] of [...this.bySession]) {
|
|
113
121
|
if (attached.session.state === "streaming")
|
|
114
122
|
continue;
|
|
123
|
+
if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
|
|
124
|
+
continue;
|
|
115
125
|
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
116
126
|
continue;
|
|
117
127
|
if (now - attached.activeAt < ttlMs)
|
|
@@ -231,7 +241,7 @@ export class Router {
|
|
|
231
241
|
// its own queues up to the agent_end handler, so a non-empty queue on an
|
|
232
242
|
// idle session is exactly the message that missed that window.
|
|
233
243
|
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
234
|
-
this.promoteQueued(session
|
|
244
|
+
this.promoteQueued(session);
|
|
235
245
|
}
|
|
236
246
|
// Every turn-end reaches the channel, empty text included: an adapter's
|
|
237
247
|
// per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
|
|
@@ -254,10 +264,124 @@ export class Router {
|
|
|
254
264
|
unsubscribe,
|
|
255
265
|
});
|
|
256
266
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
queueOperations = new Set();
|
|
268
|
+
promotionRequested = new Set();
|
|
269
|
+
recoveries = new Map();
|
|
270
|
+
// Keep the latest failed batch ID so a clear cannot erase a newer rejection
|
|
271
|
+
// that arrived while it awaited the backend. ACK affects only the copy.
|
|
272
|
+
uncertaintyHeld = new Map();
|
|
273
|
+
queueUncertain(sessionId) {
|
|
274
|
+
return this.uncertaintyHeld.has(sessionId);
|
|
275
|
+
}
|
|
276
|
+
recoveryOf(sessionId) {
|
|
277
|
+
return structuredClone(this.recoveries.get(sessionId) ?? []);
|
|
278
|
+
}
|
|
279
|
+
recoveryChanged(sessionId) {
|
|
280
|
+
if (!this.recoveries.get(sessionId)?.length)
|
|
281
|
+
this.recoveries.delete(sessionId);
|
|
282
|
+
this.hub.emit(sessionId, {
|
|
283
|
+
type: "queue-recovery", batches: this.recoveryOf(sessionId), uncertain: this.queueUncertain(sessionId),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
acknowledgeRecovery(sessionId, batchId) {
|
|
287
|
+
const batches = this.recoveries.get(sessionId);
|
|
288
|
+
const batch = batches?.find((b) => b.id === batchId);
|
|
289
|
+
if (!batch)
|
|
290
|
+
throw new QueueOperationError("missing", "No such recovery batch");
|
|
291
|
+
if (batch.status === "submitting")
|
|
292
|
+
throw new QueueOperationError("busy", "Submission has not settled");
|
|
293
|
+
this.recoveries.set(sessionId, batches.filter((b) => b !== batch));
|
|
294
|
+
this.promotionRequested.delete(sessionId);
|
|
295
|
+
this.recoveryChanged(sessionId);
|
|
296
|
+
}
|
|
297
|
+
/** Only queue mutation owns this lock. A model turn owns its batch, not the
|
|
298
|
+
* next queue: manual controls remain available while that turn runs. */
|
|
299
|
+
async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
|
|
300
|
+
if (this.queueOperations.has(sessionId))
|
|
301
|
+
throw new QueueOperationError("busy", "Queue operation in progress");
|
|
302
|
+
this.queueOperations.add(sessionId);
|
|
303
|
+
try {
|
|
304
|
+
return await action(await this.ensure(key));
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
this.queueOperations.delete(sessionId);
|
|
308
|
+
this.resumePromotion(sessionId);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async recallQueue(sessionId) {
|
|
312
|
+
return this.useQueue(sessionId, async (session) => {
|
|
313
|
+
const held = this.uncertaintyHeld.get(sessionId);
|
|
314
|
+
const queue = await session.clearQueue();
|
|
315
|
+
if (this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
316
|
+
this.recoveryChanged(sessionId);
|
|
317
|
+
if (queue.steering.length || queue.followUp.length)
|
|
318
|
+
this.forgetSender(sessionId);
|
|
319
|
+
return queue;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
checkQueueDrain() {
|
|
323
|
+
if (this.draining)
|
|
324
|
+
throw new QueueOperationError("draining", "Pier is restarting; queued messages were not submitted");
|
|
325
|
+
}
|
|
326
|
+
async deliverQueue(sessionId, mode) {
|
|
327
|
+
let retained = false;
|
|
328
|
+
try {
|
|
329
|
+
this.checkQueueDrain();
|
|
330
|
+
return await this.useQueue(sessionId, async (session) => {
|
|
331
|
+
this.checkQueueDrain();
|
|
332
|
+
if (mode === "auto" && (session.state !== "idle" || this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length))
|
|
333
|
+
return "";
|
|
334
|
+
const held = this.uncertaintyHeld.get(sessionId);
|
|
335
|
+
const queue = await session.clearQueue();
|
|
336
|
+
if (mode !== "auto" && this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
337
|
+
this.recoveryChanged(sessionId);
|
|
338
|
+
if (!queue.steering.length && !queue.followUp.length)
|
|
339
|
+
throw new QueueOperationError("empty", "Queue is empty");
|
|
340
|
+
const batch = { id: randomUUID(), ...queue, status: "submitting" };
|
|
341
|
+
this.recoveries.set(sessionId, [...(this.recoveries.get(sessionId) ?? []), batch]);
|
|
342
|
+
retained = true;
|
|
343
|
+
this.recoveryChanged(sessionId);
|
|
344
|
+
const text = [...batch.steering, ...batch.followUp].join("\n");
|
|
345
|
+
let invoked = false;
|
|
346
|
+
const failed = (err) => {
|
|
347
|
+
this.forgetSender(sessionId);
|
|
348
|
+
this.promotionRequested.delete(sessionId);
|
|
349
|
+
batch.status = invoked ? "uncertain" : "not-submitted";
|
|
350
|
+
if (invoked)
|
|
351
|
+
this.uncertaintyHeld.set(sessionId, batch.id);
|
|
352
|
+
batch.error = String(err);
|
|
353
|
+
this.recoveryChanged(sessionId);
|
|
354
|
+
this.reportTo(sessionId, `Queue promotion failed (${batch.status}); automatic queue paused, originals remain available in queue recovery: ${String(err)}`);
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
this.checkQueueDrain();
|
|
358
|
+
if (mode === "restart")
|
|
359
|
+
await this.abort(sessionId);
|
|
360
|
+
this.checkQueueDrain();
|
|
361
|
+
// Already headed at original dispatch. Calling dispatch again would
|
|
362
|
+
// discard rejection and could attribute these words to the operator.
|
|
363
|
+
invoked = true;
|
|
364
|
+
const submitted = mode === "steer" && session.state === "streaming"
|
|
365
|
+
? session.steer(text) : session.prompt(text);
|
|
366
|
+
void submitted.then(() => {
|
|
367
|
+
this.recoveries.set(sessionId, (this.recoveries.get(sessionId) ?? []).filter((b) => b !== batch));
|
|
368
|
+
this.recoveryChanged(sessionId);
|
|
369
|
+
}, failed).finally(() => this.resumePromotion(sessionId));
|
|
370
|
+
return text;
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
failed(err);
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}, mode === "auto" ? this.conversationOf(sessionId) : undefined);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (!retained && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
|
|
380
|
+
this.reportTo(sessionId, `Could not promote queued messages: ${String(err)}`);
|
|
381
|
+
}
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
261
385
|
/**
|
|
262
386
|
* Turn a stranded queue into the turn it was waiting for. Not routed through
|
|
263
387
|
* `dispatch`: the text was prefixed when it was first dispatched
|
|
@@ -268,40 +392,39 @@ export class Router {
|
|
|
268
392
|
* start the very turn it was asked to stop. Recovering *those* messages stays
|
|
269
393
|
* the web's recall route, which hands them back to the composer.
|
|
270
394
|
*/
|
|
271
|
-
promoteQueued(session
|
|
272
|
-
if (session.state !== "idle"
|
|
395
|
+
promoteQueued(session) {
|
|
396
|
+
if (session.state !== "idle")
|
|
273
397
|
return;
|
|
274
|
-
this.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
log.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
catch (err) {
|
|
296
|
-
this.report(session.id, key, `delivering the queued messages failed: ${String(err)}`);
|
|
297
|
-
}
|
|
298
|
-
finally {
|
|
299
|
-
this.promoting.delete(session.id);
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
398
|
+
this.promotionRequested.add(session.id);
|
|
399
|
+
this.resumePromotion(session.id);
|
|
400
|
+
}
|
|
401
|
+
resumePromotion(sessionId) {
|
|
402
|
+
// queue_update precedes the backend's enqueue. Never clear it reentrantly.
|
|
403
|
+
queueMicrotask(() => {
|
|
404
|
+
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
|
|
405
|
+
return;
|
|
406
|
+
// A queue event followed by rejection might describe the same input.
|
|
407
|
+
// Retained failures require a human decision, not an automatic resend.
|
|
408
|
+
if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
|
|
409
|
+
return;
|
|
410
|
+
this.promotionRequested.delete(sessionId);
|
|
411
|
+
const attached = this.bySession.get(sessionId);
|
|
412
|
+
if (!attached || attached.session.state !== "idle")
|
|
413
|
+
return;
|
|
414
|
+
void this.deliverQueue(sessionId, "auto").catch((err) => {
|
|
415
|
+
// deliverQueue reports failures; empty/busy simply lost the race.
|
|
416
|
+
log.debug(`automatic promotion of ${sessionId} stopped: ${String(err)}`);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
302
419
|
}
|
|
303
420
|
async abort(sessionId) {
|
|
304
|
-
|
|
421
|
+
this.promotionRequested.delete(sessionId);
|
|
422
|
+
try {
|
|
423
|
+
await this.bySession.get(sessionId)?.session.abort();
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
this.promotionRequested.delete(sessionId);
|
|
427
|
+
}
|
|
305
428
|
}
|
|
306
429
|
/**
|
|
307
430
|
* Drop what this session was told about who is speaking, so the next message
|
|
@@ -358,7 +481,9 @@ export class Router {
|
|
|
358
481
|
return this.byKey.get(keyOf(key));
|
|
359
482
|
}
|
|
360
483
|
async abortConversation(key) {
|
|
361
|
-
|
|
484
|
+
const session = this.sessionOf(key);
|
|
485
|
+
if (session)
|
|
486
|
+
await this.abort(session.id);
|
|
362
487
|
}
|
|
363
488
|
/** Session owning a conversation, resolving and attaching it on first use. */
|
|
364
489
|
async ensure(key) {
|
package/dist/db.js
CHANGED
|
@@ -310,6 +310,12 @@ const MIGRATIONS = [
|
|
|
310
310
|
GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
|
|
311
311
|
CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
|
|
312
312
|
`,
|
|
313
|
+
// 17 — stable global pages, including sparse lists with unmatched probes hidden.
|
|
314
|
+
`
|
|
315
|
+
CREATE INDEX task_runs_time_id ON task_runs(queued_at DESC, id DESC);
|
|
316
|
+
CREATE INDEX task_runs_visible_time ON task_runs(queued_at DESC, id DESC)
|
|
317
|
+
WHERE NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0);
|
|
318
|
+
`,
|
|
313
319
|
];
|
|
314
320
|
/**
|
|
315
321
|
* Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
|
package/dist/main.js
CHANGED
|
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { serve } from "@hono/node-server";
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { PiConfigStore } from "./agent/config.js";
|
|
7
|
+
import { normalizeAgentSnapshot } from "./agent/config-sync.js";
|
|
8
|
+
import { ConfigSync } from "./config-sync.js";
|
|
9
|
+
import { configSyncTask } from "./config-sync-task.js";
|
|
7
10
|
import { CredentialStore } from "./agent/credentials.js";
|
|
8
11
|
import { PiAgentFactory } from "./agent/pi.js";
|
|
9
12
|
import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
|
|
@@ -35,6 +38,7 @@ import { startUpdate, unitPath, updaterProblem } from "./service.js";
|
|
|
35
38
|
import { SettingsStore } from "./settings.js";
|
|
36
39
|
import { startAutoUpdate, UpdateCheck } from "./update.js";
|
|
37
40
|
import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
|
|
41
|
+
import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
|
|
38
42
|
import { PushStore, registerPushRoutes } from "./web/push.js";
|
|
39
43
|
import { SessionStateStore } from "./web/session-state.js";
|
|
40
44
|
import { createServer } from "./web/server.js";
|
|
@@ -86,7 +90,12 @@ let channelStore;
|
|
|
86
90
|
// Shared by the adapter and the tool: a display name is looked up once per
|
|
87
91
|
// process, not once per message and again per transcript.
|
|
88
92
|
const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
|
|
93
|
+
let readyForConfigReload = false;
|
|
89
94
|
const piConfig = new PiConfigStore();
|
|
95
|
+
const configSync = new ConfigSync({
|
|
96
|
+
db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
|
|
97
|
+
reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
|
|
98
|
+
});
|
|
90
99
|
const factory = new PiAgentFactory([
|
|
91
100
|
taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
|
|
92
101
|
slackToolSpec((params, callerSessionId) => handleSlackTool({
|
|
@@ -143,8 +152,9 @@ const router = new Router(hub, (key) => {
|
|
|
143
152
|
const stopEviction = router.startIdleEviction();
|
|
144
153
|
tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
145
154
|
modelMenu: () => settings.get().modelMenu,
|
|
155
|
+
systemActions: { "config-sync": (signal) => configSync.sync(signal) },
|
|
146
156
|
});
|
|
147
|
-
tasks
|
|
157
|
+
const configurationSync = configSyncTask(tasks, configSync);
|
|
148
158
|
// The managed CLI tools (src/tools.ts), and the daily task that keeps them
|
|
149
159
|
// current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
|
|
150
160
|
// wired here because tools.ts may not import tasks/.
|
|
@@ -181,6 +191,19 @@ const reloadInstance = async (includeWatched = false) => {
|
|
|
181
191
|
await channels.reload();
|
|
182
192
|
return router.evictIdle(0, Date.now(), { includeWatched });
|
|
183
193
|
};
|
|
194
|
+
// Bound the startup check before sessions and scheduled work can start.
|
|
195
|
+
// Remote outages keep the last local configuration available.
|
|
196
|
+
if (configSync.status().enabled) {
|
|
197
|
+
try {
|
|
198
|
+
await configSync.sync();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
log.error("Startup configuration sync failed; using the last local configuration");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
await configurationSync.reconcile();
|
|
205
|
+
tasks.start();
|
|
206
|
+
readyForConfigReload = true;
|
|
184
207
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
185
208
|
// Replacing Pier is systemd's job, not this process's: the oneshot unit stops
|
|
186
209
|
// the service, snapshots the database, installs and starts it again. Without
|
|
@@ -289,8 +312,15 @@ app.onError((err, c) => {
|
|
|
289
312
|
// so a surface added later is covered without knowing this exists. Built
|
|
290
313
|
// before the listener: a first run generates and prints its password here.
|
|
291
314
|
const auth = new AuthStore(db);
|
|
315
|
+
registerConfigShareRoute(app, configSync);
|
|
292
316
|
app.use("*", requireAuth(auth));
|
|
293
317
|
registerAuthRoutes(app, auth);
|
|
318
|
+
registerConfigSyncRoutes(app, {
|
|
319
|
+
sync: configSync,
|
|
320
|
+
status: () => ({ ...configurationSync.status(), publicUrl: settings.get().publicUrl }),
|
|
321
|
+
reconcile: configurationSync.reconcile,
|
|
322
|
+
run: configurationSync.run,
|
|
323
|
+
});
|
|
294
324
|
registerTaskRoutes(app, tasks, { factory, router });
|
|
295
325
|
registerChannelRoutes(app, channelStore, channels);
|
|
296
326
|
registerBoardRoutes(app);
|
package/dist/tasks/agent.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// A run that *is* a Pi session: which session it opens (
|
|
1
|
+
// A run that *is* a Pi session: which session it opens (fresh or reused),
|
|
2
2
|
// what the child is told before the prompt, and how many may run at once. The
|
|
3
3
|
// concurrency caps are here rather than in execution.ts because they bound
|
|
4
4
|
// agents specifically — a bash run costs a process, an agent run costs a
|
|
@@ -52,7 +52,7 @@ export class AgentTaskRunner {
|
|
|
52
52
|
await this.waitUntilIdle(session, signal);
|
|
53
53
|
// Task requests come seconds apart — the 1h Anthropic cache-write premium
|
|
54
54
|
// never earns its 2× back, so task runs use the 5m TTL. Set here, not in
|
|
55
|
-
// resolveSession: this covers create,
|
|
55
|
+
// resolveSession: this covers create, reuse and resume alike, on
|
|
56
56
|
// every attempt — and only after the session is idle, so a reused
|
|
57
57
|
// interactive session's in-flight turn keeps its 1h writes.
|
|
58
58
|
session.setCacheRetention("short");
|
|
@@ -149,25 +149,16 @@ export class AgentTaskRunner {
|
|
|
149
149
|
if (run.targetSessionId) {
|
|
150
150
|
return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
|
|
151
151
|
}
|
|
152
|
-
const source = run.sourceSessionId
|
|
153
|
-
? await this.factory.find(run.sourceSessionId)
|
|
154
|
-
: undefined;
|
|
155
152
|
const policy = action.session;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
else if (policy.mode === "fresh") {
|
|
163
|
-
cwd = policy.cwd;
|
|
164
|
-
}
|
|
165
|
-
else if (policy.mode === "reuse") {
|
|
166
|
-
cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
cwd = policy.cwd ?? source?.cwd ?? "";
|
|
153
|
+
// Definitions stored before fork was removed still say `"fork"`. Refused by
|
|
154
|
+
// name: every directory this could pick instead is a guess at what the
|
|
155
|
+
// author meant, and a child in the wrong tree edits real files.
|
|
156
|
+
if (run.sessionMode === "fork" || policy.mode === "fork") {
|
|
157
|
+
throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
|
|
170
158
|
}
|
|
159
|
+
const cwd = policy.mode === "fresh"
|
|
160
|
+
? policy.cwd
|
|
161
|
+
: (await this.factory.find(policy.sessionId))?.cwd ?? "";
|
|
171
162
|
if (!cwd)
|
|
172
163
|
throw new Error("could not resolve child working directory");
|
|
173
164
|
const opts = {
|
|
@@ -179,9 +170,7 @@ export class AgentTaskRunner {
|
|
|
179
170
|
(run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
|
|
180
171
|
thinking: action.launch?.thinking,
|
|
181
172
|
};
|
|
182
|
-
const session =
|
|
183
|
-
? await this.factory.fork(run.sourceSessionId, opts)
|
|
184
|
-
: await this.factory.create(opts);
|
|
173
|
+
const session = await this.factory.create(opts);
|
|
185
174
|
run.targetSessionId = session.id;
|
|
186
175
|
run.context.sessionId = session.id;
|
|
187
176
|
run.context.cwd = cwd;
|
package/dist/tasks/callbacks.js
CHANGED
|
@@ -19,7 +19,7 @@ export const runSource = (run) => ({
|
|
|
19
19
|
});
|
|
20
20
|
export function runResultText(run) {
|
|
21
21
|
let result = run.error ?? "No result";
|
|
22
|
-
if (run.result?.type === "agent")
|
|
22
|
+
if (run.result?.type === "agent" || run.result?.type === "system")
|
|
23
23
|
result = run.result.text;
|
|
24
24
|
if (run.result?.type === "bash")
|
|
25
25
|
result = run.result.stdout || run.result.stderr || `exit ${String(run.result.exitCode)}`;
|