@timqi/pier 0.1.0 → 0.1.2
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 +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +85 -0
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
package/dist/log.js
CHANGED
|
@@ -1,52 +1,35 @@
|
|
|
1
|
-
// What Pier writes to its own log
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// log configuration. Under systemd that *is* the log: journald stamps the time,
|
|
5
|
-
// keeps the history, rotates it and lets `journalctl -p warning` filter it
|
|
6
|
-
// (docs/deploy.md), and in a terminal it is the terminal. A logger that opened
|
|
7
|
-
// its own file would duplicate all of that and hide half the output from
|
|
8
|
-
// `journalctl` — the one place an operator actually looks.
|
|
9
|
-
//
|
|
10
|
-
// Like paths.ts, this is a leaf everything may import and that imports nothing:
|
|
11
|
-
// a log line is not a seam crossing, so no area owns it.
|
|
1
|
+
// What Pier writes to its own log. stdout/stderr only: under systemd journald
|
|
2
|
+
// is the log (timestamps, history, rotation, `journalctl -p warning`), and a
|
|
3
|
+
// file of our own would hide half the output from it.
|
|
12
4
|
import { homedir } from "node:os";
|
|
13
5
|
const ORDER = ["debug", "info", "warn", "error"];
|
|
14
6
|
/** `silent` exists for test runs, which drive failure paths on purpose. */
|
|
15
7
|
const THRESHOLDS = [...ORDER, "silent"];
|
|
16
8
|
const isThreshold = (value) => THRESHOLDS.includes(value);
|
|
17
9
|
const RANK = { debug: 0, info: 1, warn: 2, error: 3, silent: 4 };
|
|
18
|
-
/** `PIER_LOG=debug` turns on the per-message tracing; default keeps it out. */
|
|
19
10
|
const raw = (process.env.PIER_LOG ?? "").toLowerCase();
|
|
20
11
|
const threshold = isThreshold(raw) ? raw : "info";
|
|
21
|
-
// systemd sets JOURNAL_STREAM when
|
|
22
|
-
//
|
|
23
|
-
// plain stream tells journald its priority (sd-daemon(3)), so the same line
|
|
24
|
-
// that reads well in a terminal stays greppable *and* filterable by level.
|
|
12
|
+
// systemd sets JOURNAL_STREAM when output goes to the journal; a `<N>` prefix
|
|
13
|
+
// is how a plain stream tells journald its priority (sd-daemon(3)).
|
|
25
14
|
const toJournal = process.env.JOURNAL_STREAM !== undefined;
|
|
26
15
|
const PRIORITY = { debug: "<7>", info: "<6>", warn: "<4>", error: "<3>" };
|
|
27
|
-
/** `$HOME`
|
|
28
|
-
* Skipped when `$HOME` is `/` (containers do this), where it would rewrite
|
|
29
|
-
* every slash in every message. */
|
|
16
|
+
/** Skipped when `$HOME` is `/` (containers), where it would rewrite every slash. */
|
|
30
17
|
const home = homedir();
|
|
31
18
|
const shorten = (text) => home.length > 1 ? text.replaceAll(home, "~") : text;
|
|
32
|
-
/** An Error contributes its stack — the post-mortem is why it was logged. */
|
|
33
19
|
const describe = (cause) => cause instanceof Error ? (cause.stack ?? `${cause.name}: ${cause.message}`) : String(cause);
|
|
34
20
|
function write(level, area, message, cause) {
|
|
35
21
|
if (RANK[level] < RANK[threshold])
|
|
36
22
|
return;
|
|
37
23
|
const text = shorten(cause === undefined ? message : `${message}: ${describe(cause)}`);
|
|
38
|
-
// Per line
|
|
39
|
-
//
|
|
40
|
-
// newline in something a browser reported would let it forge a level.
|
|
24
|
+
// Per line: journald reads a prefix off each line, and a newline in
|
|
25
|
+
// something a browser reported would otherwise let it forge a level.
|
|
41
26
|
const line = toJournal
|
|
42
27
|
? text.split("\n").map((part, i) => `${PRIORITY[level]}${i === 0 ? `${area}: ` : ""}${part}`).join("\n")
|
|
43
28
|
: `${new Date().toISOString()} ${level.toUpperCase().padEnd(5)} ${area}: ${text}`;
|
|
44
|
-
// Warnings and errors on stderr: it is what journald and every wrapper
|
|
45
|
-
// already treat as the abnormal stream, with or without the prefix above.
|
|
46
29
|
const stream = level === "warn" || level === "error" ? process.stderr : process.stdout;
|
|
47
30
|
stream.write(`${line}\n`);
|
|
48
31
|
}
|
|
49
|
-
/**
|
|
32
|
+
/** The area is the grep handle, so keep it stable. */
|
|
50
33
|
export const logger = (area) => ({
|
|
51
34
|
debug: (message, cause) => write("debug", area, message, cause),
|
|
52
35
|
info: (message, cause) => write("info", area, message, cause),
|
package/dist/main.js
CHANGED
|
@@ -27,6 +27,7 @@ import { deliverLedger, drainForRestart, RestartLedger } from "./drain.js";
|
|
|
27
27
|
import { bundledInfo } from "./extensions/index.js";
|
|
28
28
|
import { surfacePrompt } from "./core/reply.js";
|
|
29
29
|
import { Router } from "./core/router.js";
|
|
30
|
+
import { acquireInstanceLock } from "./lock.js";
|
|
30
31
|
import { logger } from "./log.js";
|
|
31
32
|
import { registerTaskRoutes } from "./tasks/routes.js";
|
|
32
33
|
import { TaskService } from "./tasks/service.js";
|
|
@@ -45,51 +46,41 @@ import { PushStore, registerPushRoutes } from "./web/push.js";
|
|
|
45
46
|
import { SessionStateStore } from "./web/session-state.js";
|
|
46
47
|
import { createServer } from "./web/server.js";
|
|
47
48
|
const log = logger("pier");
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// An operator override wins — but only a human's. Everything Pier spawns (the
|
|
53
|
-
// agent's own shell, task sessions) inherits this variable, so a second
|
|
54
|
-
// Pier started from inside the first with its own PIER_HOME would adopt the
|
|
55
|
-
// first one's agent dir and write its sessions, SYSTEM.md and models.json
|
|
56
|
-
// there: two instances sharing a directory neither was told to share, and
|
|
57
|
-
// PIER_HOME looking like it did nothing. PIER_AGENT_DIR marks the value as
|
|
58
|
-
// ours, and a value that is ours is not an override, it is a leak — derive it
|
|
59
|
-
// again from this instance's own PIER_HOME.
|
|
49
|
+
// Before any SDK call resolves a path, so everything Pi derives from its agent
|
|
50
|
+
// dir lands under PIER_HOME. PIER_AGENT_DIR marks the value as ours: a second
|
|
51
|
+
// Pier spawned from inside the first inherits it, and an inherited value is a
|
|
52
|
+
// leak, not an operator override (paths.ts).
|
|
60
53
|
process.env.PI_CODING_AGENT_DIR = resolveAgentDir(process.env);
|
|
61
54
|
process.env.PIER_AGENT_DIR = process.env.PI_CODING_AGENT_DIR;
|
|
62
|
-
// Ahead of everything Pier spawns — sessions and tasks all inherit this
|
|
63
|
-
// process's env. A tool switched on in the Console is Pier's
|
|
64
|
-
// copy at Pier's version, so it goes first, not last.
|
|
65
55
|
prependPath(process.env);
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
56
|
+
// Before the database and before task recovery, which would mark the running
|
|
57
|
+
// instance's queued and active runs interrupted; the port is discovered far too
|
|
58
|
+
// late, and a second Pier on another port would never notice at all.
|
|
59
|
+
const lock = acquireInstanceLock();
|
|
60
|
+
if ("heldBy" in lock) {
|
|
61
|
+
log.error(`another Pier (pid ${lock.heldBy === null ? "unknown" : String(lock.heldBy)}) owns ${PIER_HOME} — refusing to start`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// Every exit path at once: the drain and both shutdowns end in `process.exit`,
|
|
65
|
+
// and the crash that skips this is what the stale-claim takeover is for.
|
|
66
|
+
process.on("exit", lock.release);
|
|
67
|
+
// A schema that cannot be migrated must stop the process before a port is open.
|
|
69
68
|
const db = pierDb();
|
|
70
|
-
//
|
|
71
|
-
// pier.db now, and a setting that silently stops being read is a 5b violation:
|
|
72
|
-
// the operator who wrote it deserves to hear that it no longer applies.
|
|
69
|
+
// A setting that silently stops being read is a §5 violation.
|
|
73
70
|
for (const stale of ["settings.json", "pins.json", "unread.json"]) {
|
|
74
71
|
if (existsSync(pierPath(stale))) {
|
|
75
72
|
log.warn(`${pierPath(stale)} is no longer read — its value lives in pier.db now; re-enter it in the Console and delete the file`);
|
|
76
73
|
}
|
|
77
74
|
}
|
|
78
|
-
// One store, two readers: the Console writes the public URL, and every session
|
|
79
|
-
// opened after that is told the new one.
|
|
80
75
|
const settings = new SettingsStore(db);
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// nothing that needs a token may run before the key arrives.
|
|
76
|
+
// Unlocked below: vt mode waits on a human approval, and nothing that needs a
|
|
77
|
+
// token may run before the key arrives.
|
|
84
78
|
const secrets = new Secrets();
|
|
85
79
|
let tasks;
|
|
86
80
|
const conversations = new ConversationStore(db);
|
|
87
81
|
let resolveIm;
|
|
88
|
-
// Declared before the store exists because the factory is built first; the tool
|
|
89
|
-
// only ever runs long after wiring is done.
|
|
90
82
|
let channelStore;
|
|
91
|
-
// Shared by the adapter and the tool:
|
|
92
|
-
// process, not once per message and again per transcript.
|
|
83
|
+
// Shared by the adapter and the tool: one display-name lookup per process.
|
|
93
84
|
const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
|
|
94
85
|
let readyForConfigReload = false;
|
|
95
86
|
const piConfig = new PiConfigStore();
|
|
@@ -102,15 +93,12 @@ const factory = new PiAgentFactory([
|
|
|
102
93
|
slackToolSpec((params, callerSessionId) => handleSlackTool({
|
|
103
94
|
store: channelStore,
|
|
104
95
|
directory: slackDirectory,
|
|
105
|
-
//
|
|
106
|
-
// and a client captured at boot would keep using the old one.
|
|
96
|
+
// Per call: the Console can change the token underneath us.
|
|
107
97
|
client: () => {
|
|
108
98
|
const config = channelStore.get("slack");
|
|
109
99
|
return config.token ? new SlackApi(config.token, config.appToken) : null;
|
|
110
100
|
},
|
|
111
|
-
//
|
|
112
|
-
// no ids. Looked up per call: the mapping is durable, the session is
|
|
113
|
-
// not.
|
|
101
|
+
// Per call: the mapping is durable, the session is not.
|
|
114
102
|
here: (sessionId) => {
|
|
115
103
|
const key = router.conversationOf(sessionId);
|
|
116
104
|
if (key?.channelId !== "slack")
|
|
@@ -119,55 +107,33 @@ const factory = new PiAgentFactory([
|
|
|
119
107
|
return channel && threadTs ? { channel, threadTs } : null;
|
|
120
108
|
},
|
|
121
109
|
log: (m) => logger("slack.tool").warn(m),
|
|
122
|
-
}, params, callerSessionId),
|
|
123
|
-
// No Slack, no schema: an unconfigured tool would sit in every prompt of
|
|
124
|
-
// every session and be able to answer nothing.
|
|
125
|
-
() => slackToolAvailable(channelStore)),
|
|
110
|
+
}, params, callerSessionId), () => slackToolAvailable(channelStore)),
|
|
126
111
|
],
|
|
127
|
-
//
|
|
128
|
-
//
|
|
112
|
+
// Getters, read per session open: a Console change reaches the next session
|
|
113
|
+
// without a restart.
|
|
129
114
|
() => surfacePrompt({ boardsDir: defaultBoardsDir(), publicUrl: settings.get().publicUrl }),
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// Provider credentials live sealed in pier.db; a leftover auth.json is
|
|
134
|
-
// imported on first use and renamed to auth.json.imported.
|
|
135
|
-
new CredentialStore(db, secrets), piConfig,
|
|
136
|
-
// Operator pins ride ahead of the curated catalog in every model picker.
|
|
137
|
-
() => settings.get().modelMenu,
|
|
138
|
-
// Bundled extensions the Console switched on; read per session open, so the
|
|
139
|
-
// toggle reaches the next session the same way an edited agent file does.
|
|
140
|
-
() => settings.get().extensions,
|
|
141
|
-
// The title model, if the operator picked one; read when a first turn ends.
|
|
142
|
-
() => settings.get().titleModel,
|
|
143
|
-
// The search index reads Pi's transcripts, which carry the speaker header
|
|
144
|
-
// core wrote for the model; this is where the two areas meet.
|
|
115
|
+
// Documents Pier's own tools, so it loads only inside a Pier session.
|
|
116
|
+
[fileURLToPath(new URL("../skills", import.meta.url))], new CredentialStore(db, secrets), piConfig, () => settings.get().modelMenu, () => settings.get().extensions, () => settings.get().titleModel,
|
|
117
|
+
// Transcripts carry the speaker header core wrote for the model.
|
|
145
118
|
new IndexedListing(undefined, undefined, (text) => splitSpeaker(text).text));
|
|
146
119
|
const hub = new EventHub();
|
|
147
120
|
const router = new Router(hub, (key) => {
|
|
148
|
-
// Web conversation ids
|
|
149
|
-
//
|
|
150
|
-
// cwd the chat is configured for) — a restart must not re-route a group.
|
|
121
|
+
// Web conversation ids are session ids; an IM id is a chat, resolved through
|
|
122
|
+
// the durable map so a restart does not re-route a group.
|
|
151
123
|
if (key.channelId === "web" || key.channelId === "task") {
|
|
152
124
|
return factory.resume(key.conversationId);
|
|
153
125
|
}
|
|
154
126
|
return resolveIm(key);
|
|
155
|
-
});
|
|
156
|
-
// An attached session holds a live Pi runtime and its transcript, and nothing
|
|
157
|
-
// else ever lets one go: without this, one per conversation ever answered.
|
|
127
|
+
}, (key) => conversations.get(key));
|
|
158
128
|
const stopEviction = router.startIdleEviction();
|
|
159
129
|
tasks = new TaskService(new TaskStore(db), factory, router, hub, {
|
|
160
130
|
modelMenu: () => settings.get().modelMenu,
|
|
161
131
|
systemActions: { "config-sync": (signal) => configSync.sync(signal) },
|
|
162
132
|
});
|
|
163
133
|
const configurationSync = configSyncTask(tasks, configSync);
|
|
164
|
-
// The managed CLI tools (src/tools.ts), and the daily task that keeps them
|
|
165
|
-
// current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
|
|
166
|
-
// wired here because tools.ts may not import tasks/.
|
|
167
134
|
const managedTools = new ManagedTools();
|
|
168
135
|
const toolsUpdate = toolsTask(tasks);
|
|
169
|
-
// Before any route exists: two first flips could otherwise both
|
|
170
|
-
// and create one each. A failure here is logged, and the next flip retries.
|
|
136
|
+
// Before any route exists: two first flips could otherwise both create a task.
|
|
171
137
|
const reconciled = await toolsUpdate.reconcile();
|
|
172
138
|
if ("problem" in reconciled)
|
|
173
139
|
log.error(`tools cannot be managed: ${reconciled.problem}`);
|
|
@@ -175,31 +141,25 @@ channelStore = new ChannelStore(db, secrets);
|
|
|
175
141
|
const control = createControl({ router, factory, conversations, store: channelStore });
|
|
176
142
|
const channels = new ChannelRuntime(channelStore, router, control);
|
|
177
143
|
resolveIm = resolveConversation(conversations, factory, control.launchFor, (message) => logger("channels").warn(message));
|
|
178
|
-
// Channels connect
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// Once they are up, the chats a previous restart cut off are told (drain.ts) —
|
|
182
|
-
// on this path and on a later Console unlock alike, because a note held back
|
|
183
|
-
// by locked secrets must not wait for yet another restart.
|
|
144
|
+
// Channels connect once tokens are readable; a refused unlock must not take
|
|
145
|
+
// down the web surface, which is where the operator repairs it. The chats a
|
|
146
|
+
// previous restart cut off are told as soon as channels are up (drain.ts).
|
|
184
147
|
const restartLedger = new RestartLedger(db);
|
|
185
148
|
const startChannels = async () => {
|
|
186
149
|
await channels.reload();
|
|
187
150
|
await deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
188
151
|
.catch((err) => log.error("restart-note delivery failed", err));
|
|
189
152
|
};
|
|
190
|
-
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* stored in a transcript. SIGHUP (`pier reload`) and the Console's Reload are
|
|
194
|
-
* both this call; `includeWatched` is the only difference, and only because the
|
|
195
|
-
* Console knows a person asked from the session they are looking at. */
|
|
153
|
+
/** Adapters re-read their configuration and sessions are let go, so the next
|
|
154
|
+
* message re-opens them with current skills, extensions, prompts and
|
|
155
|
+
* credentials. SIGHUP and the Console's Reload are both this call. */
|
|
196
156
|
const reloadInstance = async (includeWatched = false) => {
|
|
197
157
|
await channels.reload();
|
|
198
158
|
return router.evictIdle(0, Date.now(), { includeWatched });
|
|
199
159
|
};
|
|
200
|
-
// Bound the startup check before sessions and scheduled work can start.
|
|
201
160
|
// Remote outages keep the last local configuration available.
|
|
202
161
|
if (configSync.status().enabled) {
|
|
162
|
+
// sync() already logged the cause; this line says what the boot did about it.
|
|
203
163
|
try {
|
|
204
164
|
await configSync.sync();
|
|
205
165
|
}
|
|
@@ -211,28 +171,15 @@ await configurationSync.reconcile();
|
|
|
211
171
|
tasks.start();
|
|
212
172
|
readyForConfigReload = true;
|
|
213
173
|
void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
|
|
214
|
-
// Replacing Pier is systemd's job
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// that unit there is nothing to hand the work to, and the Console says so
|
|
218
|
-
// instead of offering a button that cannot work.
|
|
174
|
+
// Replacing Pier is systemd's job: the oneshot unit snapshots the database,
|
|
175
|
+
// installs, then stops and starts the service. Without that unit the Console
|
|
176
|
+
// says so instead of offering a button that cannot work.
|
|
219
177
|
const updates = new UpdateCheck();
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
// of a Pier nobody has a browser open on.
|
|
178
|
+
// At boot, not lazily: it puts the answer in the journal of a Pier nobody has
|
|
179
|
+
// a browser open on.
|
|
223
180
|
void updates.refresh();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
* `systemctl stop`, i.e. a SIGTERM, which is the *fast* teardown — so anything
|
|
227
|
-
* that started since the idle check would be killed with no note anywhere. The
|
|
228
|
-
* gate closes first and the drain waits, exactly as `pier restart` does,
|
|
229
|
-
* ledger included; only then is the install handed over. A handover that never
|
|
230
|
-
* starts reopens the gate, because a Pier that silently refuses every message
|
|
231
|
-
* forever is worse than the race it was avoiding.
|
|
232
|
-
*/
|
|
233
|
-
// Shared restart state. The updater's handover, the SIGUSR2 drain (below) and
|
|
234
|
-
// the final teardown must see each other: without this, two paths drain the
|
|
235
|
-
// same Pier at once, and a failure on one reopens the gate the other still
|
|
181
|
+
// The updater's handover, the SIGUSR2 drain and the teardown must see each
|
|
182
|
+
// other, or two paths drain the same Pier and one reopens the gate the other
|
|
236
183
|
// needs shut.
|
|
237
184
|
let handingOver = false;
|
|
238
185
|
let draining = false;
|
|
@@ -240,32 +187,23 @@ let shuttingDown = false;
|
|
|
240
187
|
const takeWorkAgain = (why) => {
|
|
241
188
|
handingOver = false;
|
|
242
189
|
log.error(`${why} — taking work again`);
|
|
243
|
-
// Not ours to reopen: a
|
|
244
|
-
// and reopening it would hand new work to a process that is exiting.
|
|
190
|
+
// Not ours to reopen: a restart or the teardown owns the gate now.
|
|
245
191
|
if (draining || shuttingDown)
|
|
246
192
|
return;
|
|
247
193
|
router.endDrain();
|
|
248
194
|
tasks.unpause();
|
|
249
|
-
//
|
|
250
|
-
// restart that was supposed to follow, that debt would wait for one days
|
|
251
|
-
// away (§5b) — so the chats are told now, by the process that cut them off.
|
|
195
|
+
// Turns the drain deadline-aborted must not wait for a restart days away (§5).
|
|
252
196
|
void deliverLedger(restartLedger, (entry) => channels.notify(entry.channelId, entry.conversationId, entry.note))
|
|
253
197
|
.catch((err) => log.error("restart-note delivery failed", err));
|
|
254
198
|
};
|
|
255
|
-
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
* someone else's network — ten seconds on a good day, minutes on a bad one.
|
|
260
|
-
* So this waits far longer than the stop itself needs, because reopening the
|
|
261
|
-
* gate mid-install would take work we are about to be SIGTERM'd out of, and
|
|
262
|
-
* still short enough that a handover which never happens does not refuse
|
|
263
|
-
* messages all afternoon. */
|
|
199
|
+
/** `systemctl start --no-block` returns when the job is queued; the real
|
|
200
|
+
* outcome is a SIGTERM after a registry download on someone else's network.
|
|
201
|
+
* Long enough not to reopen the gate mid-install, short enough that a handover
|
|
202
|
+
* that never happens does not refuse messages all afternoon. */
|
|
264
203
|
const HANDOVER_GRACE_MS = 5 * 60_000;
|
|
265
204
|
const handOverToUpdater = async () => {
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// twice, each believing the gate is its own to reopen on failure.
|
|
205
|
+
// The updater's first act is `systemctl stop`, the fast teardown: the gate
|
|
206
|
+
// closes and the drain waits first, as `pier restart` does.
|
|
269
207
|
if (handingOver || draining || shuttingDown)
|
|
270
208
|
return "busy";
|
|
271
209
|
handingOver = true;
|
|
@@ -275,11 +213,8 @@ const handOverToUpdater = async () => {
|
|
|
275
213
|
takeWorkAgain(`update not started (${started})`);
|
|
276
214
|
return started;
|
|
277
215
|
}
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// masked, the job sat behind another — would leave Pier alive and refusing
|
|
281
|
-
// every message with no way back. Unref'd: this must not be what keeps the
|
|
282
|
-
// process up while systemd is trying to stop it.
|
|
216
|
+
// A handover that queues and goes nowhere would leave Pier refusing every
|
|
217
|
+
// message with no way back. Unref'd: must not keep the process up under stop.
|
|
283
218
|
setTimeout(() => {
|
|
284
219
|
takeWorkAgain(`still running ${String(HANDOVER_GRACE_MS / 1000)}s after handing over — pier-update.service never stopped Pier` +
|
|
285
220
|
` (check: journalctl --user -u pier-update.service -e)`);
|
|
@@ -289,20 +224,17 @@ const handOverToUpdater = async () => {
|
|
|
289
224
|
const updater = process.platform === "linux" && existsSync(unitPath())
|
|
290
225
|
? { apply: handOverToUpdater, problem: () => updaterProblem() }
|
|
291
226
|
: null;
|
|
292
|
-
// Unattended only when the operator asked for it *and* nothing is running.
|
|
293
227
|
if (updater) {
|
|
294
228
|
const problem = updaterProblem();
|
|
295
|
-
//
|
|
296
|
-
//
|
|
229
|
+
// At boot, when the operator is looking; the alternative is a restart that
|
|
230
|
+
// fails months from now.
|
|
297
231
|
if (problem)
|
|
298
232
|
log.warn(`the updater cannot run: ${problem}`);
|
|
299
233
|
startAutoUpdate(updates, {
|
|
300
234
|
enabled: () => settings.get().autoUpdate,
|
|
301
235
|
idle: () => router.busy().length === 0 && tasks.activeRunCount() === 0,
|
|
302
236
|
apply: async () => {
|
|
303
|
-
//
|
|
304
|
-
// recorded Node months into an uptime, and draining for a handover that
|
|
305
|
-
// cannot happen would take the whole instance down with it.
|
|
237
|
+
// A version manager can remove the recorded Node months into an uptime.
|
|
306
238
|
const now = updaterProblem();
|
|
307
239
|
if (now) {
|
|
308
240
|
log.error(`auto-update skipped: ${now}`);
|
|
@@ -312,18 +244,14 @@ if (updater) {
|
|
|
312
244
|
},
|
|
313
245
|
});
|
|
314
246
|
}
|
|
315
|
-
// Composition happens here so web/ and tasks/ never import each other.
|
|
316
247
|
const app = new Hono();
|
|
317
|
-
//
|
|
318
|
-
// Hono's default handler writes nothing to the log, so the operator sees a
|
|
319
|
-
// failed request in the browser and an empty journal.
|
|
248
|
+
// Hono's default handler writes nothing to the log.
|
|
320
249
|
app.onError((err, c) => {
|
|
321
250
|
log.error(`${c.req.method} ${c.req.path} failed`, err);
|
|
322
251
|
return c.json({ error: String(err) }, 500);
|
|
323
252
|
});
|
|
324
|
-
// Before every route
|
|
325
|
-
//
|
|
326
|
-
// before the listener: a first run generates and prints its password here.
|
|
253
|
+
// Before every route: Hono runs middleware in registration order, so a surface
|
|
254
|
+
// added later is covered without knowing this exists.
|
|
327
255
|
const auth = new AuthStore(db);
|
|
328
256
|
registerConfigShareRoute(app, configSync);
|
|
329
257
|
app.use("*", requireAuth(auth));
|
|
@@ -338,11 +266,8 @@ registerTaskRoutes(app, tasks, { factory, router });
|
|
|
338
266
|
registerChannelRoutes(app, channelStore, channels);
|
|
339
267
|
registerBoardRoutes(app);
|
|
340
268
|
const sessionState = new SessionStateStore(db);
|
|
341
|
-
// The workbench's notifications to a browser that is not open. Composed here,
|
|
342
|
-
// beside the other surfaces: it consumes the same event stream the web server
|
|
343
|
-
// does, and neither one runs the other.
|
|
344
269
|
registerPushRoutes(app, {
|
|
345
|
-
store: new PushStore(db),
|
|
270
|
+
store: new PushStore(db, secrets),
|
|
346
271
|
hub,
|
|
347
272
|
unread: (id) => sessionState.unread(id),
|
|
348
273
|
channelOf: (id) => router.conversationOf(id)?.channelId,
|
|
@@ -357,11 +282,8 @@ app.route("/", createServer({
|
|
|
357
282
|
config: piConfig,
|
|
358
283
|
providers: factory,
|
|
359
284
|
settings,
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
// One list, assembled where both halves are visible: the extensions Pier
|
|
363
|
-
// loads from inside itself and the binaries it installs are the same kind of
|
|
364
|
-
// switch to the person flipping it, and rtk is both.
|
|
285
|
+
// Assembled here so web/ gets names and summaries, not a module that
|
|
286
|
+
// imports the Pi SDK; extensions and binaries are one kind of switch.
|
|
365
287
|
catalog: async () => {
|
|
366
288
|
const { extensions, tools, customTools } = settings.get();
|
|
367
289
|
return {
|
|
@@ -369,14 +291,10 @@ app.route("/", createServer({
|
|
|
369
291
|
toolsTaskId: toolsUpdate.id(),
|
|
370
292
|
};
|
|
371
293
|
},
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
// code, never against a catalog whose custom half the request may be
|
|
375
|
-
// rewriting.
|
|
294
|
+
// A switch is validated against what this Pier *can* switch, never against
|
|
295
|
+
// a catalog whose custom half the request may be rewriting.
|
|
376
296
|
names: { extensions: bundledInfo([]).map((entry) => entry.name), tools: MANAGED.map((tool) => tool.name) },
|
|
377
297
|
onToolsChanged: toolsUpdate.changed,
|
|
378
|
-
// The rule lives with the installer; the names the bundled catalog already
|
|
379
|
-
// owns live with the extensions. Only here are both in scope.
|
|
380
298
|
validateCustomTools: (raw) => {
|
|
381
299
|
const validated = normalizeCustomTools(raw, bundledInfo([]).map((entry) => entry.name));
|
|
382
300
|
return validated ? { tools: validated } : { error: CUSTOM_TOOL_RULES };
|
|
@@ -384,7 +302,6 @@ app.route("/", createServer({
|
|
|
384
302
|
secrets,
|
|
385
303
|
updates,
|
|
386
304
|
updater,
|
|
387
|
-
// Unlocked from the Console: start the channels boot held back.
|
|
388
305
|
onUnlocked: () => void startChannels(),
|
|
389
306
|
reload: () => reloadInstance(true),
|
|
390
307
|
backgroundRuns: (id) => tasks.backgroundRuns(id),
|
|
@@ -394,56 +311,45 @@ app.route("/", createServer({
|
|
|
394
311
|
}));
|
|
395
312
|
const port = Number(process.env.PORT ?? 3141);
|
|
396
313
|
const hostname = process.env.HOST ?? "127.0.0.1";
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
// silently changes what half the ecosystem builds; PORT and HOST would aim an
|
|
401
|
-
// agent's own dev server at Pier's socket. Deleted at runtime rather than only
|
|
402
|
-
// dropped from the unit, because an installed unit is rewritten by
|
|
403
|
-
// `pier service install --force` and by nothing else.
|
|
314
|
+
// Every command a turn runs inherits this env: `NODE_ENV=production` makes an
|
|
315
|
+
// agent's `npm install` skip devDependencies, and PORT/HOST would aim its dev
|
|
316
|
+
// server at Pier's socket.
|
|
404
317
|
for (const leak of ["NODE_ENV", "PORT", "HOST"])
|
|
405
318
|
delete process.env[leak];
|
|
406
319
|
const server = serve({ fetch: app.fetch, port, hostname }, () => {
|
|
407
320
|
log.info(`workbench on http://${hostname}:${port}`);
|
|
408
321
|
log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
|
|
409
|
-
//
|
|
410
|
-
// the one thing about this process's paths that cannot be guessed from it.
|
|
322
|
+
// The one path that cannot be guessed from PIER_HOME.
|
|
411
323
|
if (process.env.PI_CODING_AGENT_DIR !== pierPath("pi")) {
|
|
412
324
|
log.info(`agent dir ${process.env.PI_CODING_AGENT_DIR} (PI_CODING_AGENT_DIR)`);
|
|
413
325
|
}
|
|
414
326
|
});
|
|
415
|
-
// A crash and a clean stop must be distinguishable after the fact, and both
|
|
416
|
-
// left nothing behind before this.
|
|
417
327
|
process.on("uncaughtException", (err) => {
|
|
418
328
|
log.error("uncaught exception, exiting", err);
|
|
419
329
|
process.exit(1); // Node's own default outcome, with the area named
|
|
420
330
|
});
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
// every scheduled task down with it — so it is logged loudly and Pier serves on.
|
|
331
|
+
// Node's default is to crash; a stray rejection in one adapter's background
|
|
332
|
+
// work must not take every session and scheduled task down with it.
|
|
424
333
|
process.on("unhandledRejection", (reason) => {
|
|
425
334
|
log.error("unhandled rejection", reason);
|
|
426
335
|
});
|
|
427
336
|
const shutdown = (stopTasks = true) => {
|
|
428
|
-
//
|
|
429
|
-
// racing each other close the same sockets twice.
|
|
337
|
+
// SIGTERM can land while a drain is finishing.
|
|
430
338
|
if (shuttingDown)
|
|
431
339
|
return;
|
|
432
340
|
shuttingDown = true;
|
|
433
|
-
//
|
|
434
|
-
//
|
|
341
|
+
// A socket an adapter cannot close must not turn `systemctl restart` into a
|
|
342
|
+
// 90-second wait for SIGKILL.
|
|
435
343
|
setTimeout(() => process.exit(0), 3000).unref();
|
|
436
344
|
stopEviction();
|
|
437
|
-
// The drain path leaves task runs alone: aborting
|
|
438
|
-
//
|
|
439
|
-
// boot-time interrupted marking is the recovery that was promised.
|
|
345
|
+
// The drain path leaves task runs alone: aborting would record them cancelled,
|
|
346
|
+
// when the boot-time interrupted marking is the recovery that was promised.
|
|
440
347
|
if (stopTasks)
|
|
441
348
|
tasks.stop();
|
|
442
349
|
void channels.stop().finally(() => {
|
|
443
350
|
server.close(() => process.exit(0));
|
|
444
351
|
// Every workbench tab holds an SSE stream open, so `close()` alone would
|
|
445
|
-
//
|
|
446
|
-
// union with HTTP/2, which has no such method — and no such problem.)
|
|
352
|
+
// wait out the timer above. (`in`: the served type is a union with HTTP/2.)
|
|
447
353
|
if ("closeAllConnections" in server)
|
|
448
354
|
server.closeAllConnections();
|
|
449
355
|
});
|
|
@@ -454,10 +360,8 @@ for (const signal of ["SIGTERM", "SIGINT"]) {
|
|
|
454
360
|
shutdown();
|
|
455
361
|
});
|
|
456
362
|
}
|
|
457
|
-
// The slow restart (`pier restart`):
|
|
458
|
-
//
|
|
459
|
-
// next process up. SIGTERM above stays the fast path systemd expects. `on`,
|
|
460
|
-
// not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
363
|
+
// The slow restart (`pier restart`): drain, then exit for `Restart=always`.
|
|
364
|
+
// `on`, not `once`: a second SIGUSR2 with no handler would fall back to Node's
|
|
461
365
|
// default and kill the drain it meant to hurry.
|
|
462
366
|
process.on("SIGUSR2", () => {
|
|
463
367
|
if (draining) {
|
|
@@ -470,11 +374,8 @@ process.on("SIGUSR2", () => {
|
|
|
470
374
|
.catch((err) => log.error("drain failed — shutting down anyway", err))
|
|
471
375
|
.then(() => shutdown(false));
|
|
472
376
|
});
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// Only under systemd (the CLI signals through systemctl): a foreground `pier
|
|
476
|
-
// serve` keeps SIGHUP's default, dying with its terminal instead of surviving
|
|
477
|
-
// as an orphan that holds the port.
|
|
377
|
+
// Only under systemd: a foreground `pier serve` keeps SIGHUP's default, dying
|
|
378
|
+
// with its terminal instead of surviving as an orphan that holds the port.
|
|
478
379
|
if (process.env.INVOCATION_ID) {
|
|
479
380
|
process.on("SIGHUP", () => {
|
|
480
381
|
log.info("SIGHUP received, reloading channels and recycling idle sessions");
|
package/dist/paths.js
CHANGED
|
@@ -1,36 +1,20 @@
|
|
|
1
|
-
// Where Pier keeps its state, resolved once.
|
|
2
|
-
//
|
|
3
|
-
// This is process configuration, not a per-call decision: the same
|
|
4
|
-
// `PIER_HOME ?? ~/.pier` line had grown six copies, one per module that needed
|
|
5
|
-
// a file, and no area could own the fix — channels/ must not import tasks/,
|
|
6
|
-
// web/ must not import channels/. So it lives in a leaf that everything may
|
|
7
|
-
// depend on and that depends on nothing.
|
|
1
|
+
// Where Pier keeps its state, resolved once, in a leaf every area may import.
|
|
8
2
|
import { homedir } from "node:os";
|
|
9
3
|
import { join } from "node:path";
|
|
10
|
-
/** Empty is unset, not a value: `PIER_HOME=`
|
|
11
|
-
*
|
|
12
|
-
* database, the boards and the master key would land wherever the process
|
|
13
|
-
* happened to start. Pure, because that rule is worth a test. */
|
|
4
|
+
/** Empty is unset, not a value: `PIER_HOME=` would otherwise resolve every
|
|
5
|
+
* path relative to the working directory. */
|
|
14
6
|
export const resolveHome = (value, home = homedir()) => value || join(home, ".pier");
|
|
15
|
-
/** `$PIER_HOME`, or `~/.pier`. Fixed for the life of the process. */
|
|
16
7
|
export const PIER_HOME = resolveHome(process.env.PIER_HOME);
|
|
17
|
-
/** A path inside it — `pierPath("boards")`. */
|
|
18
8
|
export const pierPath = (...parts) => join(PIER_HOME, ...parts);
|
|
19
|
-
/**
|
|
20
|
-
* directory so db.ts can lock that directory down to 0700 without touching
|
|
9
|
+
/** In its own directory so db.ts can lock it down to 0700 without touching
|
|
21
10
|
* the boards PIER_HOME also holds. */
|
|
22
11
|
export const PIER_DB = pierPath("db", "pier.db");
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* own PIER_HOME is. `PIER_AGENT_DIR` carries the value Pier itself set: when
|
|
30
|
-
* the two match, the variable is a leak rather than an instruction, and this
|
|
31
|
-
* instance derives its own. Pure, because the rule is worth a test and the
|
|
32
|
-
* process only gets to apply it once (main.ts).
|
|
33
|
-
*/
|
|
12
|
+
/** At the root of PIER_HOME: the claim is the whole instance directory, not the
|
|
13
|
+
* database (lock.ts). */
|
|
14
|
+
export const PIER_LOCK = pierPath("pier.lock");
|
|
15
|
+
/** `PI_CODING_AGENT_DIR` is an operator override — unless it equals
|
|
16
|
+
* `PIER_AGENT_DIR`, the value Pier itself set: then a Pier spawned from inside
|
|
17
|
+
* another inherited it, and a leak is not an instruction. */
|
|
34
18
|
export function resolveAgentDir(env, derived = pierPath("pi")) {
|
|
35
19
|
const given = env.PI_CODING_AGENT_DIR;
|
|
36
20
|
return !given || given === env.PIER_AGENT_DIR ? derived : given;
|