@timqi/pier 0.1.2 → 0.1.4
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 +8 -3
- package/dist/agent/config.js +63 -59
- package/dist/agent/events.js +1 -2
- package/dist/agent/listing.js +38 -15
- package/dist/agent/packages.js +347 -0
- package/dist/agent/pi.js +33 -59
- package/dist/channels/config.js +30 -20
- package/dist/channels/routes.js +0 -3
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +1 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +9 -17
- package/dist/channels/types.js +0 -1
- package/dist/cli.js +158 -7
- package/dist/core/identity.js +20 -11
- package/dist/core/reply.js +16 -15
- package/dist/core/router.js +4 -1
- package/dist/core/types.js +7 -0
- package/dist/db.js +28 -0
- package/dist/extensions/index.js +1 -20
- package/dist/main.js +34 -42
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +5 -0
- package/dist/socket.js +95 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +215 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +323 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +63 -85
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/config.js +25 -11
- package/dist/web/explorer.js +5 -3
- package/dist/web/instance.js +6 -18
- package/dist/web/packages.js +84 -0
- package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
- package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
- package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js +1 -0
- package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
- package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
- package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
- package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
- package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
- package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
- package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
- package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
- package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/server.js +3 -1
- package/dist/web/vault.js +68 -0
- package/docs/deploy.md +42 -8
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +10 -6
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +48 -145
- package/skills/pier-vault/SKILL.md +36 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/tasks/tool.js +0 -416
- 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-CneyR23E.js +0 -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 +0 -4
- 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 +0 -85
- 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 +0 -2
- 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-Cv0A-e08.js +0 -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-VmCjhGBd.js +0 -5
- 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-CqpTV644.js +0 -3
- 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 +0 -4
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
package/dist/tasks/runs.js
CHANGED
|
@@ -1,34 +1,22 @@
|
|
|
1
|
-
// A definition plus an input becomes a queued run
|
|
2
|
-
//
|
|
1
|
+
// A definition plus an input becomes a queued run; every caller enqueues
|
|
2
|
+
// through this one door.
|
|
3
3
|
import { logger } from "../log.js";
|
|
4
4
|
import { newId } from "./definitions.js";
|
|
5
5
|
const log = logger("tasks");
|
|
6
|
-
const MAX_DEPTH = 2;
|
|
7
|
-
const MAX_CHILDREN_PER_ROOT = 16;
|
|
8
6
|
export class TaskRunQueue {
|
|
9
7
|
store;
|
|
10
8
|
callbacks;
|
|
11
|
-
getRun;
|
|
12
9
|
execute;
|
|
13
10
|
changed;
|
|
14
|
-
constructor(store, callbacks,
|
|
11
|
+
constructor(store, callbacks, execute, changed) {
|
|
15
12
|
this.store = store;
|
|
16
13
|
this.callbacks = callbacks;
|
|
17
|
-
this.getRun = getRun;
|
|
18
14
|
this.execute = execute;
|
|
19
15
|
this.changed = changed;
|
|
20
16
|
}
|
|
21
17
|
/** Validate and persist only; callers may stage several runs in a transaction. */
|
|
22
18
|
prepare(definition, input, source, parentRunId, provenance) {
|
|
23
19
|
const id = newId();
|
|
24
|
-
const parent = parentRunId ? this.getRun(parentRunId) : null;
|
|
25
|
-
const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
|
|
26
|
-
const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
|
|
27
|
-
if (depth > MAX_DEPTH)
|
|
28
|
-
throw new Error(`subagent depth limit is ${MAX_DEPTH}`);
|
|
29
|
-
if (depth > 0 && this.store.listRunsByRoot(rootRunId, MAX_CHILDREN_PER_ROOT + 1).filter((run) => run.depth > 0).length >= MAX_CHILDREN_PER_ROOT) {
|
|
30
|
-
throw new Error(`subagent child limit is ${MAX_CHILDREN_PER_ROOT} per root run`);
|
|
31
|
-
}
|
|
32
20
|
const invokedBySessionId = provenance.invokedBySessionId ?? null;
|
|
33
21
|
const sourceSessionId = provenance.sourceSessionId ?? invokedBySessionId;
|
|
34
22
|
const sessionMode = definition.action.type === "agent"
|
|
@@ -50,8 +38,6 @@ export class TaskRunQueue {
|
|
|
50
38
|
taskRevision: definition.revision,
|
|
51
39
|
parentRunId,
|
|
52
40
|
groupId: provenance.groupId ?? null,
|
|
53
|
-
rootRunId,
|
|
54
|
-
depth,
|
|
55
41
|
resumedFromRunId: provenance.resumedFromRunId ?? null,
|
|
56
42
|
triggerSource: source,
|
|
57
43
|
invokedBySessionId,
|
|
@@ -86,13 +72,12 @@ export class TaskRunQueue {
|
|
|
86
72
|
/** Publish and execute only after all records this run relies on committed. */
|
|
87
73
|
start(run) {
|
|
88
74
|
this.changed(run);
|
|
89
|
-
const { id,
|
|
75
|
+
const { id, triggerSource: source } = run;
|
|
90
76
|
const overlapped = run.skipReason === "overlap";
|
|
91
77
|
const definition = run.context.definition;
|
|
92
78
|
// Why a run exists is answerable only here: the row keeps the ids, not the
|
|
93
79
|
// reason. A watch probe queues every interval, so it logs at debug.
|
|
94
|
-
const queued = `run ${id} ${run.state}: ${definition.name} via ${source}
|
|
95
|
-
`${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
|
|
80
|
+
const queued = `run ${id} ${run.state}: ${definition.name} via ${source}${overlapped ? " (overlapped)" : ""}`;
|
|
96
81
|
if (source === "watch" && !overlapped)
|
|
97
82
|
log.debug(queued);
|
|
98
83
|
else
|
package/dist/tasks/service.js
CHANGED
|
@@ -9,7 +9,7 @@ import { TaskExecution } from "./execution.js";
|
|
|
9
9
|
import { TaskGroups } from "./groups.js";
|
|
10
10
|
import { TaskMessenger } from "./messages.js";
|
|
11
11
|
import { TaskRunQueue } from "./runs.js";
|
|
12
|
-
import {
|
|
12
|
+
import { handleTask } from "./operations.js";
|
|
13
13
|
import { isTerminal } from "./types.js";
|
|
14
14
|
const log = logger("tasks");
|
|
15
15
|
/** Not `renderedPrompt`: that carries the preamble and input wrapper, which the
|
|
@@ -44,14 +44,13 @@ export class TaskService {
|
|
|
44
44
|
this.hub = hub;
|
|
45
45
|
this.instance = instance;
|
|
46
46
|
const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
|
|
47
|
-
this.messages = new TaskMessenger(store, router, hub,
|
|
47
|
+
this.messages = new TaskMessenger(store, router, hub, unreachable);
|
|
48
48
|
this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
|
|
49
49
|
this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
|
|
50
50
|
this.groups = new TaskGroups(store, router, {
|
|
51
51
|
getRun: (id) => this.getRun(id),
|
|
52
52
|
cancel: (id) => { this.cancel(id); },
|
|
53
|
-
|
|
54
|
-
prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
|
|
53
|
+
prepareMember: (taskId, groupId, callerSessionId) => this.prepareRun(taskId, null, "agent", null, {
|
|
55
54
|
invokedBySessionId: callerSessionId,
|
|
56
55
|
sourceSessionId: callerSessionId,
|
|
57
56
|
callbackSessionId: null,
|
|
@@ -72,9 +71,8 @@ export class TaskService {
|
|
|
72
71
|
cancel: (id) => { this.cancel(id); },
|
|
73
72
|
settled: (run) => this.settled(run),
|
|
74
73
|
changed: (run) => this.changed(run),
|
|
75
|
-
openDecisionId: (runId) => this.messages.openDecisionId(runId),
|
|
76
74
|
});
|
|
77
|
-
this.runs = new TaskRunQueue(store, this.callbacks, (
|
|
75
|
+
this.runs = new TaskRunQueue(store, this.callbacks, (run) => this.execution.start(run), (run) => this.changed(run));
|
|
78
76
|
}
|
|
79
77
|
start(tickMs = 1000) {
|
|
80
78
|
if (this.timer)
|
|
@@ -147,7 +145,7 @@ export class TaskService {
|
|
|
147
145
|
create(raw, creator = "http") {
|
|
148
146
|
return this.definitions.create(raw, creator);
|
|
149
147
|
}
|
|
150
|
-
/** `by` is how owning code says so; the routes and
|
|
148
|
+
/** `by` is how owning code says so; the routes and `pier task` have none (definitions.ts). */
|
|
151
149
|
update(id, raw, by) {
|
|
152
150
|
return this.definitions.update(id, raw, by);
|
|
153
151
|
}
|
|
@@ -169,16 +167,13 @@ export class TaskService {
|
|
|
169
167
|
}
|
|
170
168
|
getRunView(id) {
|
|
171
169
|
const run = this.getRun(id);
|
|
172
|
-
return { ...run,
|
|
170
|
+
return { ...run,
|
|
173
171
|
groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
|
|
174
172
|
}
|
|
175
173
|
listMessages(runId) {
|
|
176
174
|
this.getRun(runId);
|
|
177
175
|
return this.messages.list(runId);
|
|
178
176
|
}
|
|
179
|
-
openDecisionId(runId) {
|
|
180
|
-
return this.messages.openDecisionId(runId);
|
|
181
|
-
}
|
|
182
177
|
queryRuns(query = {}) {
|
|
183
178
|
return this.store.queryRuns(query);
|
|
184
179
|
}
|
|
@@ -227,7 +222,8 @@ export class TaskService {
|
|
|
227
222
|
set.add(resolve);
|
|
228
223
|
});
|
|
229
224
|
}
|
|
230
|
-
/** Cascades:
|
|
225
|
+
/** Cascades down a `task` action's chain: a child must not outlive the run
|
|
226
|
+
* that waits on it. */
|
|
231
227
|
cancel(id) {
|
|
232
228
|
const run = this.getRun(id);
|
|
233
229
|
for (const target of [run, ...this.descendants(run)]) {
|
|
@@ -242,23 +238,15 @@ export class TaskService {
|
|
|
242
238
|
getGroup(id) {
|
|
243
239
|
return this.groups.members(id);
|
|
244
240
|
}
|
|
245
|
-
runGroup(definitions, join, callerSessionId,
|
|
246
|
-
this.refusePaused(
|
|
247
|
-
return this.groups.runAll(definitions, join, callerSessionId,
|
|
241
|
+
runGroup(definitions, join, callerSessionId, callbackSessionId, callbackMode) {
|
|
242
|
+
this.refusePaused();
|
|
243
|
+
return this.groups.runAll(definitions, join, callerSessionId, callbackSessionId, callbackMode);
|
|
248
244
|
}
|
|
249
245
|
descendants(run) {
|
|
250
|
-
const byParent = new Map();
|
|
251
|
-
for (const member of this.store.listRunsByRoot(run.rootRunId, 500)) {
|
|
252
|
-
if (!member.parentRunId)
|
|
253
|
-
continue;
|
|
254
|
-
const siblings = byParent.get(member.parentRunId) ?? [];
|
|
255
|
-
siblings.push(member);
|
|
256
|
-
byParent.set(member.parentRunId, siblings);
|
|
257
|
-
}
|
|
258
246
|
const collected = [];
|
|
259
247
|
const queue = [run.id];
|
|
260
248
|
while (queue.length > 0) {
|
|
261
|
-
for (const child of
|
|
249
|
+
for (const child of this.store.listChildRuns(queue.shift())) {
|
|
262
250
|
collected.push(child);
|
|
263
251
|
queue.push(child.id);
|
|
264
252
|
}
|
|
@@ -273,20 +261,7 @@ export class TaskService {
|
|
|
273
261
|
throw new Error("terminal run cannot be steered; resume it instead");
|
|
274
262
|
return this.messages.control(run, fromSessionId, mode, message);
|
|
275
263
|
}
|
|
276
|
-
reply(messageId, fromSessionId, message) {
|
|
277
|
-
return this.messages.reply(messageId, fromSessionId, message);
|
|
278
|
-
}
|
|
279
264
|
resume(id, message, provenance = {}) {
|
|
280
|
-
const { run, expired } = this.store.transact(() => ({
|
|
281
|
-
run: this.prepareResume(id, message, provenance),
|
|
282
|
-
expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
|
|
283
|
-
}));
|
|
284
|
-
for (const message of expired)
|
|
285
|
-
this.messages.changed(message);
|
|
286
|
-
this.runs.start(run);
|
|
287
|
-
return run;
|
|
288
|
-
}
|
|
289
|
-
prepareResume(id, message, provenance) {
|
|
290
265
|
this.refusePaused();
|
|
291
266
|
const prior = this.getRun(id);
|
|
292
267
|
if (!isTerminal(prior.state))
|
|
@@ -295,19 +270,20 @@ export class TaskService {
|
|
|
295
270
|
throw new Error("only persisted Agent runs can be resumed");
|
|
296
271
|
}
|
|
297
272
|
const prompt = requiredString(message, "message");
|
|
298
|
-
|
|
273
|
+
const run = this.runs.prepare(prior.context.definition, null, "agent", null, {
|
|
299
274
|
...provenance,
|
|
300
275
|
sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
|
|
301
276
|
targetSessionId: prior.targetSessionId,
|
|
302
277
|
sessionMode: "reuse",
|
|
303
278
|
resumedFromRunId: prior.id,
|
|
304
|
-
rootRunId: prior.rootRunId,
|
|
305
|
-
depth: prior.depth,
|
|
306
279
|
resumePrompt: prompt,
|
|
307
280
|
});
|
|
281
|
+
this.runs.start(run);
|
|
282
|
+
return run;
|
|
308
283
|
}
|
|
309
|
-
|
|
310
|
-
|
|
284
|
+
/** What `pier task` asks, under the calling session's identity. */
|
|
285
|
+
handle(raw, callerSessionId) {
|
|
286
|
+
return handleTask(this, this.definitions, this.store, raw, callerSessionId);
|
|
311
287
|
}
|
|
312
288
|
/** An agent picks from names that exist right now, never from memory. */
|
|
313
289
|
async models() {
|
|
@@ -375,7 +351,6 @@ export class TaskService {
|
|
|
375
351
|
state: run.state,
|
|
376
352
|
targetSessionId: run.targetSessionId,
|
|
377
353
|
sessionMode: run.sessionMode,
|
|
378
|
-
depth: run.depth,
|
|
379
354
|
prompt: runPrompt(run),
|
|
380
355
|
queuedAt: run.queuedAt,
|
|
381
356
|
startedAt: run.startedAt,
|
package/dist/tasks/store.js
CHANGED
|
@@ -104,16 +104,13 @@ export class TaskStore {
|
|
|
104
104
|
}
|
|
105
105
|
const limit = clamp(query.limit ?? 50, 200);
|
|
106
106
|
const rows = this.db.prepare(`
|
|
107
|
-
SELECT r.json, g.callback_state AS group_callback_state
|
|
108
|
-
(SELECT m.id FROM task_messages m WHERE m.run_id = r.id
|
|
109
|
-
AND m.state IN ('pending', 'delivered') AND json_extract(m.json, '$.kind') = 'decision'
|
|
110
|
-
ORDER BY m.created_at, m.id LIMIT 1) AS decision_id
|
|
107
|
+
SELECT r.json, g.callback_state AS group_callback_state
|
|
111
108
|
FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
|
|
112
109
|
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
|
|
113
110
|
ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
|
|
114
111
|
`).all(...params, limit + 1);
|
|
115
112
|
const runs = rows.slice(0, limit).map((row) => ({
|
|
116
|
-
...JSON.parse(row.json),
|
|
113
|
+
...JSON.parse(row.json), groupCallbackState: row.group_callback_state,
|
|
117
114
|
}));
|
|
118
115
|
const last = runs.at(-1);
|
|
119
116
|
return {
|
|
@@ -134,12 +131,20 @@ export class TaskStore {
|
|
|
134
131
|
`, since);
|
|
135
132
|
return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
|
|
136
133
|
}
|
|
137
|
-
|
|
134
|
+
/** A `task` action's child runs; a cancel walks them. */
|
|
135
|
+
listChildRuns(parentRunId) {
|
|
138
136
|
return this.#many(`
|
|
139
137
|
SELECT json FROM task_runs
|
|
140
|
-
WHERE json_extract(json, '$.
|
|
141
|
-
ORDER BY queued_at LIMIT
|
|
142
|
-
`,
|
|
138
|
+
WHERE json_extract(json, '$.parentRunId') = ?
|
|
139
|
+
ORDER BY queued_at LIMIT 500
|
|
140
|
+
`, parentRunId);
|
|
141
|
+
}
|
|
142
|
+
/** Someone waits on this run's result — its own callback or its group's — so
|
|
143
|
+
* the run is one turn of its session and may not delegate (design 09). */
|
|
144
|
+
supervised(run) {
|
|
145
|
+
if (run.callbackSessionId !== null)
|
|
146
|
+
return true;
|
|
147
|
+
return run.groupId !== null && (this.getGroup(run.groupId)?.callbackSessionId ?? null) !== null;
|
|
143
148
|
}
|
|
144
149
|
countActiveRuns() {
|
|
145
150
|
const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
|
|
@@ -239,13 +244,8 @@ export class TaskStore {
|
|
|
239
244
|
run: row.run_json === null ? undefined : JSON.parse(row.run_json),
|
|
240
245
|
}));
|
|
241
246
|
}
|
|
242
|
-
/** Decisions are excluded: they have no timeout and stay answerable across
|
|
243
|
-
* restarts — a reply to a terminal run resumes it. */
|
|
244
247
|
expirePendingMessages() {
|
|
245
|
-
return this.#many(
|
|
246
|
-
SELECT json FROM task_messages
|
|
247
|
-
WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
|
|
248
|
-
`).map((message) => {
|
|
248
|
+
return this.#many("SELECT json FROM task_messages WHERE state = 'pending'").map((message) => {
|
|
249
249
|
message.state = "expired";
|
|
250
250
|
// "Confirmed", not "completed": the proof lives in a transcript this
|
|
251
251
|
// layer cannot see.
|
package/dist/tools.js
CHANGED
|
@@ -5,6 +5,7 @@ import { execFile } from "node:child_process";
|
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { delimiter, join } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
8
9
|
import { pierDb, transact } from "./db.js";
|
|
9
10
|
import { logger } from "./log.js";
|
|
10
11
|
import { pierPath, resolveAgentDir } from "./paths.js";
|
|
@@ -13,37 +14,34 @@ const log = logger("tools");
|
|
|
13
14
|
const UBIX_LATEST = "https://api.github.com/repos/timqi/ubix/releases/latest";
|
|
14
15
|
export const MANAGED = [
|
|
15
16
|
{
|
|
16
|
-
kind: "extension",
|
|
17
17
|
name: "rtk",
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
// The hooks register and unregister its Pi extension; `--auto-patch` keeps
|
|
19
|
+
// `rtk init` non-interactive and overwrites a stale copy, which is right
|
|
20
|
+
// because nothing else writes Pier's agent dir.
|
|
21
|
+
toml: `spec = "github:rtk-ai/rtk"\n` +
|
|
22
|
+
`post_install = ["rtk", "init", "-g", "--agent", "pi", "--auto-patch"]\n` +
|
|
23
|
+
`pre_remove = ["rtk", "init", "--uninstall", "--agent", "pi", "--global", "--auto-patch"]`,
|
|
24
|
+
summary: "Compresses long bash output before it reaches the model. Its install hook " +
|
|
25
|
+
"writes its own Pi extension (extensions/rtk.ts, listed under the local " +
|
|
26
|
+
"package) — refreshed on every update.",
|
|
25
27
|
},
|
|
26
28
|
{
|
|
27
|
-
kind: "tool",
|
|
28
29
|
name: "rg",
|
|
29
30
|
// `exe`: ubi looks for files named after the project, and ripgrep ships `rg`.
|
|
30
31
|
toml: `spec = "github:BurntSushi/ripgrep"\nexe = "rg"`,
|
|
31
32
|
summary: "ripgrep: searches a tree by content, fast enough to be the default.",
|
|
32
33
|
},
|
|
33
34
|
{
|
|
34
|
-
kind: "tool",
|
|
35
35
|
name: "fd",
|
|
36
36
|
toml: `spec = "github:sharkdp/fd"`,
|
|
37
37
|
summary: "Finds files by name, respecting .gitignore — what `find` should feel like.",
|
|
38
38
|
},
|
|
39
39
|
{
|
|
40
|
-
kind: "tool",
|
|
41
40
|
name: "wt",
|
|
42
41
|
toml: `spec = "github:max-sixty/worktrunk"\nexe = "wt"`,
|
|
43
42
|
summary: "worktrunk: git worktrees as one command — branch, switch, merge, clean up.",
|
|
44
43
|
},
|
|
45
44
|
{
|
|
46
|
-
kind: "tool",
|
|
47
45
|
name: "jq",
|
|
48
46
|
// No `exe`: jq publishes bare per-platform binaries, which ubi installs
|
|
49
47
|
// under the tool's own name.
|
|
@@ -138,6 +136,22 @@ export function prependPath(env = process.env, bin = toolsBin()) {
|
|
|
138
136
|
mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
|
|
139
137
|
env.PATH = current ? `${bin}${delimiter}${current}` : bin;
|
|
140
138
|
}
|
|
139
|
+
/** `pier` on an agent's PATH is this process's own cli — the same source, the
|
|
140
|
+
* same loader (`tsx` in dev) — not whichever install `npm i -g` left behind.
|
|
141
|
+
* Placed from this module, not argv[1]: `pier serve` enters through cli.*.
|
|
142
|
+
* The one line naming the harness variable: the cli reads `PIER_SESSION_ID`
|
|
143
|
+
* only, and an explicit one wins. */
|
|
144
|
+
export function writePierShim(bin = toolsBin(), proc = process, cli = fileURLToPath(import.meta.url).replace(/tools(\.[cm]?[jt]s)$/, "cli$1")) {
|
|
145
|
+
const quote = (s) => `'${s.replaceAll("'", String.raw `'\''`)}'`;
|
|
146
|
+
const script = `#!/bin/sh\nexport PIER_SESSION_ID="\${PIER_SESSION_ID:-$PI_SESSION_ID}"\nexec ${[proc.execPath, ...proc.execArgv, cli].map(quote).join(" ")} "$@"\n`;
|
|
147
|
+
mkdirSync(bin, { recursive: true });
|
|
148
|
+
const path = join(bin, "pier");
|
|
149
|
+
const tmp = `${path}.${randomUUID()}`;
|
|
150
|
+
writeFileSync(tmp, script);
|
|
151
|
+
chmodSync(tmp, 0o755);
|
|
152
|
+
renameSync(tmp, path); // a shell mid-exec never sees a half-written file
|
|
153
|
+
return path;
|
|
154
|
+
}
|
|
141
155
|
/** Long enough that one Console page open spawns `ubix list` once, short
|
|
142
156
|
* enough that an install done outside Pier shows up while they look. */
|
|
143
157
|
const LIST_TTL_MS = 3_000;
|
|
@@ -339,17 +353,14 @@ export function ubixConfigToml(tools, installDir) {
|
|
|
339
353
|
lines.push("", `[tools.${tool.name}]`, tool.toml.trim());
|
|
340
354
|
return `${lines.join("\n")}\n`;
|
|
341
355
|
}
|
|
342
|
-
/**
|
|
343
|
-
*
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
/** Pier put that binary in `bin/`, so `sync` re-bootstraps rather than reports. */
|
|
347
|
-
class UbixTooOld extends Error {
|
|
348
|
-
}
|
|
356
|
+
/** The first ubix that runs `post_install` / `pre_remove`. An older one parses
|
|
357
|
+
* the keys as unknown and ignores them, so rtk would install without its
|
|
358
|
+
* extension and nothing would say so. Release tags are `vYYYYMMDD-<sha>`. */
|
|
359
|
+
const UBIX_MIN = "v20260910-61f07ae";
|
|
349
360
|
/** A name in neither list is not an error: a row a future release drops must
|
|
350
361
|
* not stop the sync of everything else. */
|
|
351
362
|
function rows(custom) {
|
|
352
|
-
return [...MANAGED, ...custom.map((tool) => ({
|
|
363
|
+
return [...MANAGED, ...custom.map((tool) => ({ summary: "", custom: true, ...tool }))];
|
|
353
364
|
}
|
|
354
365
|
export function ubixAsset(tag, platform, arch) {
|
|
355
366
|
const os = platform === "linux" || platform === "darwin" ? platform : null;
|
|
@@ -465,26 +476,11 @@ export class ManagedTools {
|
|
|
465
476
|
return { entries: [], failed: false, summary: "no tools switched on" };
|
|
466
477
|
}
|
|
467
478
|
fence();
|
|
468
|
-
const ubix = await this.bootstrapUbix();
|
|
469
479
|
const env = this.#env();
|
|
480
|
+
const ubix = await this.#current(await this.bootstrapUbix(), env);
|
|
470
481
|
const entries = [];
|
|
471
|
-
// ubix cannot know that rtk must uninstall its own Pi extension *before*
|
|
472
|
-
// its binary goes; tools leaving the set undo their footprint first.
|
|
473
|
-
const kept = [];
|
|
474
|
-
for (const state of await this.#listing(ubix, env)) {
|
|
475
|
-
const leaving = all.find((tool) => tool.name === state.name);
|
|
476
|
-
if (!leaving?.deprovision || wanted.some((tool) => tool.name === state.name))
|
|
477
|
-
continue;
|
|
478
|
-
fence(); // a tool's own uninstall is a change to the machine
|
|
479
|
-
const error = await this.#provision(env, leaving, leaving.deprovision);
|
|
480
|
-
if (error) {
|
|
481
|
-
// Removing it now would orphan what deprovision failed to remove.
|
|
482
|
-
kept.push(leaving);
|
|
483
|
-
entries.push({ name: leaving.name, action: "kept", version: null, error });
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
482
|
fence();
|
|
487
|
-
this.#writeConfig(
|
|
483
|
+
this.#writeConfig(wanted);
|
|
488
484
|
// `--prune` removes what the config no longer declares; `--wait` lets a
|
|
489
485
|
// hand-typed `pier tools sync` converge behind the managed run.
|
|
490
486
|
fence();
|
|
@@ -492,25 +488,19 @@ export class ManagedTools {
|
|
|
492
488
|
for (const state of states) {
|
|
493
489
|
if (wanted.some((tool) => tool.name === state.name))
|
|
494
490
|
continue;
|
|
495
|
-
|
|
496
|
-
continue;
|
|
497
|
-
// A failure to remove is as much a failure as one to install.
|
|
491
|
+
// A failure to remove (a `pre_remove` hook included) is as much a failure as one to install.
|
|
498
492
|
entries.push({ name: state.name, action: state.action ?? "removed", version: null, error: state.error });
|
|
499
493
|
}
|
|
500
494
|
for (const tool of wanted) {
|
|
501
495
|
const state = states.find((s) => s.name === tool.name);
|
|
502
|
-
|
|
496
|
+
entries.push({
|
|
503
497
|
name: tool.name,
|
|
504
498
|
action: state?.action ?? "missing",
|
|
505
499
|
version: state?.to ?? state?.version ?? null,
|
|
506
|
-
// A tool ubix never mentioned is not a tool that is fine
|
|
500
|
+
// A tool ubix never mentioned is not a tool that is fine; a failed
|
|
501
|
+
// `post_install` arrives here as `error` beside an `installed` action.
|
|
507
502
|
error: state?.error ?? (state ? null : "ubix reported nothing about it"),
|
|
508
|
-
};
|
|
509
|
-
if (!entry.error && tool.provision) {
|
|
510
|
-
fence();
|
|
511
|
-
entry.error = await this.#provision(env, tool, tool.provision);
|
|
512
|
-
}
|
|
513
|
-
entries.push(entry);
|
|
503
|
+
});
|
|
514
504
|
}
|
|
515
505
|
const failed = entries.some((entry) => entry.error !== null);
|
|
516
506
|
return { entries, failed, summary: summarize(entries) };
|
|
@@ -519,8 +509,6 @@ export class ManagedTools {
|
|
|
519
509
|
* version is unknown (§5). */
|
|
520
510
|
async status(enabled, custom = []) {
|
|
521
511
|
const base = rows(custom).map((tool) => ({
|
|
522
|
-
source: "binary",
|
|
523
|
-
kind: tool.kind,
|
|
524
512
|
name: tool.name,
|
|
525
513
|
summary: tool.summary,
|
|
526
514
|
enabled: enabled.includes(tool.name),
|
|
@@ -577,12 +565,20 @@ export class ManagedTools {
|
|
|
577
565
|
return states;
|
|
578
566
|
}
|
|
579
567
|
/** `UBIX_CONFIG_DIR` / `UBIX_DATA_DIR` name the directories directly — not
|
|
580
|
-
* XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too.
|
|
568
|
+
* XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too.
|
|
569
|
+
* Hooks inherit this env: `pier tools sync` typed in a shell has no main.ts
|
|
570
|
+
* parent exporting PI_CODING_AGENT_DIR, and rtk would then write its
|
|
571
|
+
* extension into ~/.pi. */
|
|
581
572
|
#env() {
|
|
573
|
+
const agentDir = resolveAgentDir(process.env);
|
|
574
|
+
if (process.env.PI_CODING_AGENT_DIR !== agentDir) {
|
|
575
|
+
log.info(`PI_CODING_AGENT_DIR was ${process.env.PI_CODING_AGENT_DIR ?? "unset"} — ubix gets ${agentDir}`);
|
|
576
|
+
}
|
|
582
577
|
const env = {
|
|
583
578
|
...process.env,
|
|
584
579
|
UBIX_CONFIG_DIR: this.#configDir,
|
|
585
580
|
UBIX_DATA_DIR: join(this.#root, "state"),
|
|
581
|
+
PI_CODING_AGENT_DIR: agentDir,
|
|
586
582
|
};
|
|
587
583
|
prependPath(env, this.bin);
|
|
588
584
|
return env;
|
|
@@ -599,8 +595,9 @@ export class ManagedTools {
|
|
|
599
595
|
renameSync(`${path}.writing`, path);
|
|
600
596
|
}
|
|
601
597
|
/** A non-zero exit still parses: under `--json` a failed tool is in the
|
|
602
|
-
* document as `action: "failed"
|
|
603
|
-
*
|
|
598
|
+
* document as `action: "failed"` — or as `installed`/`upgraded` with an
|
|
599
|
+
* `error`, when its `post_install` hook failed — and the run exits non-zero.
|
|
600
|
+
* The two must agree — an exit code with no failed entry is a failure this file cannot
|
|
604
601
|
* attribute, and passing it on as clean is the one thing it may never do. */
|
|
605
602
|
async #states(ubix, env, args) {
|
|
606
603
|
const result = await this.#exec(ubix, args, env);
|
|
@@ -610,43 +607,24 @@ export class ManagedTools {
|
|
|
610
607
|
}
|
|
611
608
|
catch (err) {
|
|
612
609
|
const failure = failedRun(`ubix ${args.join(" ")}`, result);
|
|
613
|
-
// Only the flag being unknown means "too old"; re-bootstrapping over a
|
|
614
|
-
// config error would fix nothing and say something false.
|
|
615
|
-
if (result.code !== 0 && refusesJson(result.stderr))
|
|
616
|
-
throw new UbixTooOld(failure);
|
|
617
610
|
throw new Error(result.code === 0 ? String(err) : `${failure} (${String(err)})`);
|
|
618
611
|
}
|
|
619
|
-
if (result.code !== 0 && !states.some((state) => state.
|
|
612
|
+
if (result.code !== 0 && !states.some((state) => state.error !== null)) {
|
|
620
613
|
throw new Error(`${failedRun(`ubix ${args.join(" ")}`, result)} — and its report names no failure`);
|
|
621
614
|
}
|
|
622
615
|
return states;
|
|
623
616
|
}
|
|
624
|
-
/** The one place a too-old ubix is repaired rather than reported
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
/** Returns the failure text, or null. */
|
|
637
|
-
async #provision(env, tool, args) {
|
|
638
|
-
const exe = join(this.bin, tool.name);
|
|
639
|
-
if (!existsSync(exe))
|
|
640
|
-
return `${tool.name} is not in ${this.bin} — ${args.join(" ")} was not run`;
|
|
641
|
-
// `pier tools sync` typed in a shell has no main.ts parent exporting
|
|
642
|
-
// PI_CODING_AGENT_DIR; rtk would then write its extension into ~/.pi.
|
|
643
|
-
const agentDir = resolveAgentDir(env);
|
|
644
|
-
if (env.PI_CODING_AGENT_DIR !== agentDir) {
|
|
645
|
-
log.info(`PI_CODING_AGENT_DIR was ${env.PI_CODING_AGENT_DIR ?? "unset"} — ${tool.name} gets ${agentDir}`);
|
|
646
|
-
}
|
|
647
|
-
mkdirSync(join(agentDir, "extensions"), { recursive: true });
|
|
648
|
-
const result = await this.#exec(exe, args, { ...env, PI_CODING_AGENT_DIR: agentDir });
|
|
649
|
-
return result.code === 0 ? null : failedRun(`${tool.name} ${args.join(" ")}`, result);
|
|
617
|
+
/** The one place a too-old ubix is repaired rather than reported: Pier put
|
|
618
|
+
* that binary in `bin/`, and one below UBIX_MIN would run the config and
|
|
619
|
+
* skip its hooks without a word. */
|
|
620
|
+
async #current(ubix, env) {
|
|
621
|
+
const result = await this.#exec(ubix, ["--version"], env);
|
|
622
|
+
const date = /\bv(\d{8})-/.exec(result.stdout)?.[1];
|
|
623
|
+
if (result.code === 0 && date && date >= UBIX_MIN.slice(1, 9))
|
|
624
|
+
return ubix;
|
|
625
|
+
const found = result.code === 0 ? result.stdout.trim() || "(no version)" : failedRun("ubix --version", result);
|
|
626
|
+
log.warn(`the ubix in ${this.bin} is ${found} and Pier needs ${UBIX_MIN} or newer — replacing it`);
|
|
627
|
+
return this.bootstrapUbix(true);
|
|
650
628
|
}
|
|
651
629
|
async #getText(url) {
|
|
652
630
|
return new TextDecoder().decode(await this.#getBytes(url));
|
|
@@ -662,7 +640,7 @@ export class ManagedTools {
|
|
|
662
640
|
return new Uint8Array(await res.arrayBuffer());
|
|
663
641
|
}
|
|
664
642
|
}
|
|
665
|
-
const withBinary = (entry, patch) =>
|
|
643
|
+
const withBinary = (entry, patch) => ({ ...entry, binary: { ...entry.binary, ...patch } });
|
|
666
644
|
/** Never empty: an exit code with no words is not a report. */
|
|
667
645
|
const failedRun = (what, result) => `${what} exited ${String(result.code)}: ${result.stderr.trim().slice(0, 300) || "(no output)"}`;
|
|
668
646
|
/** `<sha256> <bare filename>` lines, as `sha256sum` writes them. */
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Named secrets for `pier vault run`: the vault table and its resolution. The
|
|
2
|
+
// value's shape is the level — a sealed envelope is `auto`, a `vt://` record is
|
|
3
|
+
// `approve` — so no column can disagree with the value beside it. Plaintext is
|
|
4
|
+
// a parameter and a return value here, never a log line or a field.
|
|
5
|
+
import { pierDb } from "./db.js";
|
|
6
|
+
import { logger } from "./log.js";
|
|
7
|
+
import { isSealed, vtCli } from "./secrets.js";
|
|
8
|
+
const log = logger("vault");
|
|
9
|
+
/** An env-var name, so the common case needs no mapping. */
|
|
10
|
+
export const isVaultName = (name) => /^[A-Z][A-Z0-9_]{0,63}$/.test(name);
|
|
11
|
+
/** Named, never partial: the caller learns which name to file, and gets no
|
|
12
|
+
* values for the others. */
|
|
13
|
+
export class UnknownSecret extends Error {
|
|
14
|
+
secret;
|
|
15
|
+
constructor(secret) {
|
|
16
|
+
super(`no secret named ${secret}`);
|
|
17
|
+
this.secret = secret;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Only sealed rows need the key; the reason is the operator's repair instruction. */
|
|
21
|
+
export class VaultLocked extends Error {
|
|
22
|
+
constructor(reason) {
|
|
23
|
+
super(`locked — ${reason}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const levelOf = (value) => (isSealed(value) ? "auto" : "approve");
|
|
27
|
+
function check(name, plaintext) {
|
|
28
|
+
if (!isVaultName(name))
|
|
29
|
+
throw new Error(`${name} is not a vault name (A-Z, 0-9, _; starts with a letter)`);
|
|
30
|
+
if (!plaintext)
|
|
31
|
+
throw new Error("empty value");
|
|
32
|
+
}
|
|
33
|
+
export class Vault {
|
|
34
|
+
secrets;
|
|
35
|
+
db;
|
|
36
|
+
vt;
|
|
37
|
+
constructor(secrets, db = pierDb(), vt = vtCli) {
|
|
38
|
+
this.secrets = secrets;
|
|
39
|
+
this.db = db;
|
|
40
|
+
this.vt = vt;
|
|
41
|
+
}
|
|
42
|
+
list() {
|
|
43
|
+
const rows = this.db.prepare("SELECT name, value, updated_at FROM vault ORDER BY name").all();
|
|
44
|
+
return rows.map((row) => ({ name: row.name, level: levelOf(row.value), updatedAt: row.updated_at }));
|
|
45
|
+
}
|
|
46
|
+
/** Overwrite by name is rotation: a command already running keeps the env it
|
|
47
|
+
* was given, the next `run` gets the new value. */
|
|
48
|
+
async put(name, level, plaintext) {
|
|
49
|
+
if (level === "auto")
|
|
50
|
+
return this.seal(name, plaintext);
|
|
51
|
+
check(name, plaintext);
|
|
52
|
+
const record = await this.vt.create(plaintext);
|
|
53
|
+
if (!record.startsWith("vt://"))
|
|
54
|
+
throw new Error("vt create did not return a vt:// record");
|
|
55
|
+
this.#store(name, level, record);
|
|
56
|
+
}
|
|
57
|
+
/** The `auto` half of put, synchronous: a channel save runs on the message
|
|
58
|
+
* path and cannot await. */
|
|
59
|
+
seal(name, plaintext) {
|
|
60
|
+
check(name, plaintext);
|
|
61
|
+
this.#store(name, "auto", this.secrets.encrypt(plaintext));
|
|
62
|
+
}
|
|
63
|
+
#store(name, level, value) {
|
|
64
|
+
this.db.prepare(`
|
|
65
|
+
INSERT INTO vault(name, value, updated_at) VALUES (?, ?, ?)
|
|
66
|
+
ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
|
67
|
+
`).run(name, value, Date.now());
|
|
68
|
+
log.info(`vault put ${name} (${level})`);
|
|
69
|
+
}
|
|
70
|
+
/** False when there was nothing to remove. */
|
|
71
|
+
remove(name) {
|
|
72
|
+
const removed = this.db.prepare("DELETE FROM vault WHERE name = ?").run(name).changes > 0;
|
|
73
|
+
if (removed)
|
|
74
|
+
log.info(`vault remove ${name}`);
|
|
75
|
+
return removed;
|
|
76
|
+
}
|
|
77
|
+
/** One of Pier's own credentials, `undefined` when unfiled — a state there,
|
|
78
|
+
* not an error. A sealed row still needs the key. */
|
|
79
|
+
get(name) {
|
|
80
|
+
const row = this.db.prepare("SELECT value FROM vault WHERE name = ?").get(name);
|
|
81
|
+
if (!row)
|
|
82
|
+
return undefined;
|
|
83
|
+
return isSealed(row.value) ? this.secrets.decrypt(row.value) : row.value;
|
|
84
|
+
}
|
|
85
|
+
/** `by` names the caller in the one log line a resolve leaves; values never
|
|
86
|
+
* appear at any level. `approve` rows resolve while locked — they need no key. */
|
|
87
|
+
resolve(names, by = "unknown") {
|
|
88
|
+
const select = this.db.prepare("SELECT value FROM vault WHERE name = ?");
|
|
89
|
+
const rows = names.map((name) => {
|
|
90
|
+
const row = select.get(name);
|
|
91
|
+
if (!row)
|
|
92
|
+
throw new UnknownSecret(name);
|
|
93
|
+
return [name, row.value];
|
|
94
|
+
});
|
|
95
|
+
if (rows.some(([, value]) => isSealed(value)) && this.secrets.state === "locked") {
|
|
96
|
+
throw new VaultLocked(this.secrets.lockedReason);
|
|
97
|
+
}
|
|
98
|
+
const resolved = {};
|
|
99
|
+
for (const [name, value] of rows) {
|
|
100
|
+
resolved[name] = isSealed(value)
|
|
101
|
+
? { kind: "plain", value: this.secrets.decrypt(value) }
|
|
102
|
+
: { kind: "record", value };
|
|
103
|
+
}
|
|
104
|
+
log.info(`vault resolve ${names.join(",")} by ${by}`);
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
}
|