@timqi/pier 0.0.9 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +190 -31
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +72 -0
- package/dist/db.js +78 -0
- package/dist/extensions/index.js +5 -2
- package/dist/extensions/web/artifacts.js +7 -2
- package/dist/extensions/web/content.js +5 -0
- package/dist/extensions/web/language.js +5 -0
- package/dist/extensions/web/tools.js +33 -8
- package/dist/limits.js +14 -0
- package/dist/main.js +47 -6
- package/dist/paths.js +6 -1
- package/dist/settings.js +44 -0
- package/dist/tasks/agent.js +23 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/command.js +15 -0
- package/dist/tasks/definitions.js +60 -12
- package/dist/tasks/execution.js +9 -1
- package/dist/tasks/groups.js +8 -4
- package/dist/tasks/messages.js +10 -2
- package/dist/tasks/routes.js +4 -0
- package/dist/tasks/runs.js +7 -2
- package/dist/tasks/service.js +22 -6
- package/dist/tasks/store.js +4 -0
- package/dist/tasks/tool.js +0 -12
- package/dist/tasks/types.js +4 -0
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +165 -36
- package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
- package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
- package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +22 -7
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +145 -64
- package/dist/web/session-state.js +33 -51
- package/dist/web/types.js +5 -0
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
- package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/dist/tasks/messages.js
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
// What a parent and a child say to each other while a run is going: steer,
|
|
2
|
+
// follow-up and resume in one direction, progress and decision questions in
|
|
3
|
+
// the other. Every message is a durable row before it is a delivery, because
|
|
4
|
+
// the two ends are different sessions and either may be mid-turn, gone, or
|
|
5
|
+
// finished — an undelivered message is retried, expired and *said*, never
|
|
6
|
+
// dropped (§5b).
|
|
2
7
|
import { EventHub } from "../core/hub.js";
|
|
3
8
|
import { Router } from "../core/router.js";
|
|
4
9
|
import { logger } from "../log.js";
|
|
10
|
+
import { runSource } from "./callbacks.js";
|
|
11
|
+
import { newId } from "./definitions.js";
|
|
5
12
|
import { TaskStore } from "./store.js";
|
|
6
13
|
import { isTerminal, MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
|
|
7
14
|
const log = logger("tasks");
|
|
@@ -152,7 +159,7 @@ export class TaskMessenger {
|
|
|
152
159
|
}
|
|
153
160
|
create(run, kind, fromSessionId, toSessionId, content, replyTo) {
|
|
154
161
|
const message = {
|
|
155
|
-
id:
|
|
162
|
+
id: newId(),
|
|
156
163
|
runId: run.id,
|
|
157
164
|
kind,
|
|
158
165
|
fromSessionId,
|
|
@@ -319,6 +326,7 @@ export class TaskMessenger {
|
|
|
319
326
|
sourceSessionId: message.fromSessionId,
|
|
320
327
|
messageId: message.id,
|
|
321
328
|
messageKind: message.kind,
|
|
329
|
+
source: runSource(run),
|
|
322
330
|
};
|
|
323
331
|
}
|
|
324
332
|
require(id) {
|
package/dist/tasks/routes.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// The area's HTTP surface: tasks, runs, group and message routes for the
|
|
2
|
+
// Console, plus the Activity snapshot it draws its graph from. A route reads
|
|
3
|
+
// its body, names the caller and hands the decision to TaskService — policy
|
|
4
|
+
// that lives here would be policy the task tool does not get.
|
|
1
5
|
import { record, requiredString } from "./definitions.js";
|
|
2
6
|
const jsonBody = async (req) => req.json().catch(() => null);
|
|
3
7
|
export function registerTaskRoutes(app, tasks, activity) {
|
package/dist/tasks/runs.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
// A definition plus an input becomes a queued run: where it came from, how
|
|
2
|
+
// deep in a subagent chain it sits, whether it overlaps a run already going,
|
|
3
|
+
// and which session hears about it. The limits that keep a chain from
|
|
4
|
+
// exploding (depth, children per root) are decided here, once, because every
|
|
5
|
+
// caller — scheduler, tool, HTTP — enqueues through this one door.
|
|
2
6
|
import { logger } from "../log.js";
|
|
3
7
|
import { TaskCallbacks } from "./callbacks.js";
|
|
8
|
+
import { newId } from "./definitions.js";
|
|
4
9
|
import { TaskStore } from "./store.js";
|
|
5
10
|
const log = logger("tasks");
|
|
6
11
|
const MAX_DEPTH = 2;
|
|
@@ -19,7 +24,7 @@ export class TaskRunQueue {
|
|
|
19
24
|
this.changed = changed;
|
|
20
25
|
}
|
|
21
26
|
enqueue(definition, input, source, parentRunId, provenance) {
|
|
22
|
-
const id =
|
|
27
|
+
const id = newId();
|
|
23
28
|
const parent = parentRunId ? this.getRun(parentRunId) : null;
|
|
24
29
|
const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
|
|
25
30
|
const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
|
package/dist/tasks/service.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
// The one object the rest of Pier talks to about tasks, and the clock behind
|
|
2
|
+
// it: the tick that finds what is due, the boot recovery that writes off runs
|
|
3
|
+
// a restart interrupted, and the pause a drain needs. Every decision it looks
|
|
4
|
+
// like it makes belongs to a file beside it (definitions, runs, execution,
|
|
5
|
+
// groups, messages, callbacks) — what is genuinely here is scheduling and the
|
|
6
|
+
// facade, so the HTTP routes and the task tool cannot drift apart.
|
|
1
7
|
import { EventHub } from "../core/hub.js";
|
|
2
8
|
import { Router } from "../core/router.js";
|
|
3
9
|
import { logger } from "../log.js";
|
|
@@ -130,6 +136,14 @@ export class TaskService {
|
|
|
130
136
|
activeRunCount() {
|
|
131
137
|
return this.store.countActiveRuns();
|
|
132
138
|
}
|
|
139
|
+
/** The run this task has in flight, if any. The store already answers this
|
|
140
|
+
* for the overlap guard (runs.ts); a caller that has just been refused as
|
|
141
|
+
* an overlap needs the same answer to know what to wait for, and scanning
|
|
142
|
+
* run history for it finds nothing once the skipped rows outnumber the
|
|
143
|
+
* window. */
|
|
144
|
+
activeRun(taskId) {
|
|
145
|
+
return this.store.findActiveRun(taskId);
|
|
146
|
+
}
|
|
133
147
|
list() {
|
|
134
148
|
return this.definitions.list();
|
|
135
149
|
}
|
|
@@ -139,14 +153,16 @@ export class TaskService {
|
|
|
139
153
|
create(raw, creator = "http") {
|
|
140
154
|
return this.definitions.create(raw, creator);
|
|
141
155
|
}
|
|
142
|
-
|
|
143
|
-
|
|
156
|
+
/** `by` is how the code that owns a definition says so; the HTTP routes and
|
|
157
|
+
* the task tool have none, which is what closes both (definitions.ts). */
|
|
158
|
+
update(id, raw, by) {
|
|
159
|
+
return this.definitions.update(id, raw, by);
|
|
144
160
|
}
|
|
145
|
-
setEnabled(id, enabled) {
|
|
146
|
-
return this.definitions.setEnabled(id, enabled);
|
|
161
|
+
setEnabled(id, enabled, by) {
|
|
162
|
+
return this.definitions.setEnabled(id, enabled, by);
|
|
147
163
|
}
|
|
148
|
-
archive(id) {
|
|
149
|
-
return this.definitions.archive(id);
|
|
164
|
+
archive(id, by) {
|
|
165
|
+
return this.definitions.archive(id, by);
|
|
150
166
|
}
|
|
151
167
|
sessionExists(sessionId) {
|
|
152
168
|
return this.definitions.sessionExists(sessionId);
|
package/dist/tasks/store.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// Every query this area makes against pier.db, and nothing else: definitions,
|
|
2
|
+
// runs, groups and messages are rows here, read and written through one
|
|
3
|
+
// connection db.ts opened. A store owns its queries, never its own tables or
|
|
4
|
+
// its own handle — the schema is db.ts's migration list.
|
|
1
5
|
import { pierDb } from "../db.js";
|
|
2
6
|
const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
|
|
3
7
|
export class TaskStore {
|
package/dist/tasks/tool.js
CHANGED
|
@@ -82,9 +82,6 @@ const DraftSchema = Type.Object({
|
|
|
82
82
|
Type.Object({ mode: Type.Literal("reuse"), sessionId: Type.String() }),
|
|
83
83
|
]),
|
|
84
84
|
prompt: Type.String(),
|
|
85
|
-
// No `capabilities` here on purpose: a child gets the same tools as any
|
|
86
|
-
// Pier session, so the model never spends a decision on it. Read-only
|
|
87
|
-
// children stay configurable through the Console and HTTP.
|
|
88
85
|
launch: Type.Optional(Type.Object({
|
|
89
86
|
model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
|
|
90
87
|
thinking: Type.Optional(Type.String()),
|
|
@@ -152,9 +149,6 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
|
|
|
152
149
|
return definitions.update(requiredString(input.task_id, "task_id"), input.task);
|
|
153
150
|
}
|
|
154
151
|
if (input.operation === "run") {
|
|
155
|
-
if (active && active.context.definition.action.type === "agent" && active.context.definition.action.launch?.capabilities === "read") {
|
|
156
|
-
throw new Error("read-only subagents cannot delegate nested work");
|
|
157
|
-
}
|
|
158
152
|
if (Array.isArray(input.tasks)) {
|
|
159
153
|
// Core-joined fan-out: members run detached, one aggregated callback.
|
|
160
154
|
if (input.task !== undefined || input.task_id !== undefined)
|
|
@@ -253,12 +247,6 @@ async function resolveDraft(definitions, draft, active, callerSessionId) {
|
|
|
253
247
|
if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
|
|
254
248
|
throw new Error("inline subagent tasks must use a manual trigger");
|
|
255
249
|
}
|
|
256
|
-
// The draft parser accepts `capabilities` for Console/HTTP definitions; from
|
|
257
|
-
// the tool it is rejected rather than silently honoured, so a model working
|
|
258
|
-
// from stale memory learns the field is gone.
|
|
259
|
-
if (record(record(draft.action)?.launch)?.capabilities !== undefined) {
|
|
260
|
-
throw new Error("launch.capabilities is configured in Console or HTTP, not by the task tool");
|
|
261
|
-
}
|
|
262
250
|
if (active) {
|
|
263
251
|
const action = record(draft.action);
|
|
264
252
|
if (action?.type !== "agent")
|
package/dist/tasks/types.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// The vocabulary every file in this area shares: what a task, a run, a group
|
|
2
|
+
// and a control message *are*, plus the delivery constants the outbox and the
|
|
3
|
+
// messenger must agree on. Owner-defined and browser-importable type-only
|
|
4
|
+
// (architecture.md), so nothing here may reach for a runtime or a node builtin.
|
|
1
5
|
export const retryDelay = (attempts) => Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6));
|
|
2
6
|
/** Attempts before a delivery is given up on and reported. With the backoff
|
|
3
7
|
* above that is ~4 minutes: long enough to outlast a busy or restarting
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// One reason: a tools switch has to become exactly one run of the one task
|
|
2
|
+
// Pier owns — which takes knowing what that task runs, keeping it the task
|
|
3
|
+
// Pier wrote, and turning a burst of switches into one run of it.
|
|
4
|
+
//
|
|
5
|
+
// It lives beside tools.ts rather than inside it because tools.ts may not
|
|
6
|
+
// import tasks/, and outside main.ts because main.ts is wiring: this is the
|
|
7
|
+
// only rule in the instance layer that is neither construction nor a callback.
|
|
8
|
+
// The task's run history *is* the tools status surface — the install, the daily
|
|
9
|
+
// update and every failure are runs with output, so there is no second place to
|
|
10
|
+
// look (§5b).
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { logger } from "./log.js";
|
|
14
|
+
import { PIER_HOME } from "./paths.js";
|
|
15
|
+
import { isTerminal } from "./tasks/types.js";
|
|
16
|
+
import { coalescedSync } from "./tools.js";
|
|
17
|
+
/** Marks the daily update task as Pier's own — this file finds the one it owns
|
|
18
|
+
* rather than one a person wrote, and names itself with it when it writes the
|
|
19
|
+
* definition back (the owner guard in tasks/definitions.ts). */
|
|
20
|
+
const TOOLS_TASK_CREATOR = "tools";
|
|
21
|
+
// The area these lines have always logged under: the move must not rename
|
|
22
|
+
// anything an operator greps the journal for.
|
|
23
|
+
const log = logger("pier");
|
|
24
|
+
/** POSIX single quotes: `$`, a backtick and a backslash mean things inside
|
|
25
|
+
* double quotes, and this string is run by bash months from now. */
|
|
26
|
+
const shellQuote = (value) => `'${value.replaceAll("'", `'\\''`)}'`;
|
|
27
|
+
/**
|
|
28
|
+
* How this Pier runs `pier tools sync`. Installed, that is the built CLI beside
|
|
29
|
+
* main.js. From a source checkout there is no `cli.js` and node cannot strip
|
|
30
|
+
* types through imports that still say `.js`, so it is the same command under
|
|
31
|
+
* tsx — which is what a source checkout has. Neither available is a refusal
|
|
32
|
+
* with a reason, never a task whose script cannot run.
|
|
33
|
+
*/
|
|
34
|
+
const toolsSyncScript = () => {
|
|
35
|
+
const built = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
36
|
+
if (existsSync(built))
|
|
37
|
+
return { script: `${shellQuote(process.execPath)} ${shellQuote(built)} tools sync` };
|
|
38
|
+
const source = fileURLToPath(new URL("./cli.ts", import.meta.url));
|
|
39
|
+
if (!existsSync(source))
|
|
40
|
+
return { problem: `no CLI to run: neither ${built} nor ${source} exists` };
|
|
41
|
+
try {
|
|
42
|
+
return {
|
|
43
|
+
script: `${shellQuote(process.execPath)} --import ${shellQuote(import.meta.resolve("tsx"))}` +
|
|
44
|
+
` ${shellQuote(source)} tools sync`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { problem: `running from source (${source}) and tsx is not installed — run npm install, or npm run build` };
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
export function toolsTask(tasks) {
|
|
52
|
+
/** Which task is Pier's, and the id every managed run goes through. */
|
|
53
|
+
let toolsTaskId = null;
|
|
54
|
+
/**
|
|
55
|
+
* The one task Pier owns, brought in line with what it should be.
|
|
56
|
+
*
|
|
57
|
+
* Created once and never retired: a task that comes and goes is a state class
|
|
58
|
+
* of its own (two boots racing to create it, a retirement racing a switch),
|
|
59
|
+
* and the run it would have been retired for already says "no tools switched
|
|
60
|
+
* on".
|
|
61
|
+
*
|
|
62
|
+
* It repairs rather than trusts because of what came before the owner guard
|
|
63
|
+
* (tasks/definitions.ts): a definition edited by an older Pier, or by a
|
|
64
|
+
* release where the routes could still write it, is brought back to the one
|
|
65
|
+
* Pier owns here. Nothing can edit it any more — so this is a boot-time
|
|
66
|
+
* repair of state that already exists, not a defence.
|
|
67
|
+
*/
|
|
68
|
+
const ensureToolsTask = async () => {
|
|
69
|
+
const command = toolsSyncScript();
|
|
70
|
+
if ("problem" in command)
|
|
71
|
+
return { problem: command.problem };
|
|
72
|
+
const draft = {
|
|
73
|
+
name: "tools: daily update",
|
|
74
|
+
description: "Installs the CLI tools switched on in Settings → Agent and keeps them current.",
|
|
75
|
+
trigger: {
|
|
76
|
+
type: "cron",
|
|
77
|
+
expression: "17 4 * * *",
|
|
78
|
+
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
|
79
|
+
},
|
|
80
|
+
// PIER_HOME as cwd: the command belongs to the instance, not to a project.
|
|
81
|
+
action: { type: "bash", script: command.script, cwd: PIER_HOME },
|
|
82
|
+
timeoutSeconds: 1800,
|
|
83
|
+
};
|
|
84
|
+
const draftShape = [draft.name, draft.description, true, draft.trigger, draft.action, { type: "none" }, draft.timeoutSeconds];
|
|
85
|
+
// Archived is not "owned but edited": nothing can un-archive a task, so the
|
|
86
|
+
// replacement is a new one and the old one keeps its history.
|
|
87
|
+
const owned = tasks.list().filter((task) => task.creator === TOOLS_TASK_CREATOR && !task.archived);
|
|
88
|
+
// One per creator. Two would fight over ubix's state lock every night, each
|
|
89
|
+
// reporting the other's run as an overlap.
|
|
90
|
+
for (const extra of owned.slice(1)) {
|
|
91
|
+
log.warn(`archiving a second tools update task (${extra.id})`);
|
|
92
|
+
tasks.archive(extra.id, TOOLS_TASK_CREATOR);
|
|
93
|
+
}
|
|
94
|
+
const task = owned[0];
|
|
95
|
+
// Every field Pier owns, not just the command: a paused task, a renamed one
|
|
96
|
+
// or one pointed at a callback still claims to be keeping the tools current
|
|
97
|
+
// while the daily run never happens.
|
|
98
|
+
const current = task &&
|
|
99
|
+
JSON.stringify([task.name, task.description, task.enabled, task.trigger, task.action, task.callback, task.timeoutSeconds]);
|
|
100
|
+
if (task && current !== JSON.stringify(draftShape)) {
|
|
101
|
+
log.warn("the tools update task was edited — restoring the definition Pier owns");
|
|
102
|
+
// Named as the owner: this is the one path allowed to write it back
|
|
103
|
+
// (tasks/definitions.ts).
|
|
104
|
+
await tasks.update(task.id, { ...draft, enabled: true, callback: { type: "none" } }, TOOLS_TASK_CREATOR);
|
|
105
|
+
}
|
|
106
|
+
const id = task ? task.id : (await tasks.create(draft, TOOLS_TASK_CREATOR)).id;
|
|
107
|
+
toolsTaskId = id;
|
|
108
|
+
return { id };
|
|
109
|
+
};
|
|
110
|
+
/** The half of `coalescedSync` (tools.ts, which has the rule and why) that
|
|
111
|
+
* knows what a task is: start a run, and hand back what to wait for — our own
|
|
112
|
+
* run, or the one already in flight that made ours a `skipped` row. */
|
|
113
|
+
const requestSync = coalescedSync(() => {
|
|
114
|
+
if (!toolsTaskId)
|
|
115
|
+
throw new Error("no tools update task to run");
|
|
116
|
+
const settled = (id) => tasks.waitForRun(id).then(() => undefined);
|
|
117
|
+
// Bounded, because the only way round this loop is a run that finished
|
|
118
|
+
// between being in flight and being asked about: real, rare, and not
|
|
119
|
+
// something to spin on. Three refusals in a row with nothing running is a
|
|
120
|
+
// bug, and it is reported as one rather than retried forever.
|
|
121
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
122
|
+
const mine = tasks.run(toolsTaskId, null, "manual");
|
|
123
|
+
if (!isTerminal(mine.state))
|
|
124
|
+
return { ran: "started", settled: settled(mine.id) };
|
|
125
|
+
const active = tasks.activeRun(toolsTaskId);
|
|
126
|
+
if (active)
|
|
127
|
+
return { ran: "overlapped", settled: settled(active.id) };
|
|
128
|
+
}
|
|
129
|
+
throw new Error("the tools sync was refused as an overlap three times with nothing running");
|
|
130
|
+
}, (err) => log.error("the tools sync could not be run", err));
|
|
131
|
+
/** A switch was flipped: make sure the task is the one Pier means, then ask
|
|
132
|
+
* for a sync. Answers with what that switch should say about it. */
|
|
133
|
+
const toolsChanged = async () => {
|
|
134
|
+
try {
|
|
135
|
+
const task = await ensureToolsTask();
|
|
136
|
+
if ("problem" in task) {
|
|
137
|
+
log.error(`tools cannot be managed: ${task.problem}`);
|
|
138
|
+
return { state: "refused", reason: task.problem };
|
|
139
|
+
}
|
|
140
|
+
return { state: requestSync() };
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
log.error("the tools update task could not be reconciled", err);
|
|
144
|
+
return { state: "refused", reason: err instanceof Error ? err.message : String(err) };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
/** Reconcile now. At boot, before any route exists: two first flips could
|
|
149
|
+
* otherwise both find no task and create one each. */
|
|
150
|
+
reconcile: ensureToolsTask,
|
|
151
|
+
/** The task whose runs are the status surface, null until there is one. */
|
|
152
|
+
id: () => toolsTaskId,
|
|
153
|
+
changed: toolsChanged,
|
|
154
|
+
};
|
|
155
|
+
}
|