agent-standup 0.20.0
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/LICENSE +21 -0
- package/README.md +322 -0
- package/dist/bin/standup-hook.js +296 -0
- package/dist/bin/standup.js +3351 -0
- package/dist/chunk-4TIZQTUZ.js +19195 -0
- package/dist/chunk-N7G677FC.js +1042 -0
- package/dist/chunk-VBXNDGOD.js +203 -0
- package/dist/hook-scripts/http.js +1265 -0
- package/dist/live-R6WH7ER7.js +326 -0
- package/dist/plugin/.claude-plugin/plugin.json +9 -0
- package/dist/plugin/.mcp.json +8 -0
- package/dist/plugin/hooks/hooks.json +35 -0
- package/dist/plugin/skills/setup-agent-standup/SKILL.md +53 -0
- package/dist/run-init-VVQPJOTB.js +407 -0
- package/package.json +88 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ServiceRuntime,
|
|
3
|
+
SettingsCache,
|
|
4
|
+
prismaTransactionRunner
|
|
5
|
+
} from "./chunk-4TIZQTUZ.js";
|
|
6
|
+
import {
|
|
7
|
+
isBlockingLevel
|
|
8
|
+
} from "./chunk-VBXNDGOD.js";
|
|
9
|
+
|
|
10
|
+
// src/lib/prisma.ts
|
|
11
|
+
import { PrismaClient } from "@prisma/client";
|
|
12
|
+
|
|
13
|
+
// src/lib/db-url.ts
|
|
14
|
+
var DEFAULT_CONNECTION_LIMIT = 10;
|
|
15
|
+
var DEFAULT_POOL_TIMEOUT_SECONDS = 10;
|
|
16
|
+
function withPoolDefaults(databaseUrl, defaults = {}) {
|
|
17
|
+
const url = new URL(databaseUrl);
|
|
18
|
+
const connectionLimit = defaults.connectionLimit ?? DEFAULT_CONNECTION_LIMIT;
|
|
19
|
+
const poolTimeoutSeconds = defaults.poolTimeoutSeconds ?? DEFAULT_POOL_TIMEOUT_SECONDS;
|
|
20
|
+
const toAppend = [];
|
|
21
|
+
if (!url.searchParams.has("connection_limit")) {
|
|
22
|
+
toAppend.push(`connection_limit=${connectionLimit}`);
|
|
23
|
+
}
|
|
24
|
+
if (!url.searchParams.has("pool_timeout")) {
|
|
25
|
+
toAppend.push(`pool_timeout=${poolTimeoutSeconds}`);
|
|
26
|
+
}
|
|
27
|
+
if (toAppend.length === 0) {
|
|
28
|
+
return databaseUrl;
|
|
29
|
+
}
|
|
30
|
+
const hashIndex = databaseUrl.indexOf("#");
|
|
31
|
+
const base = hashIndex === -1 ? databaseUrl : databaseUrl.slice(0, hashIndex);
|
|
32
|
+
const hash = hashIndex === -1 ? "" : databaseUrl.slice(hashIndex);
|
|
33
|
+
const separator = base.endsWith("?") ? "" : base.includes("?") ? "&" : "?";
|
|
34
|
+
return `${base}${separator}${toAppend.join("&")}${hash}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/lib/prisma.ts
|
|
38
|
+
var globalForPrisma = globalThis;
|
|
39
|
+
function createPrismaClient() {
|
|
40
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
41
|
+
if (!databaseUrl) {
|
|
42
|
+
throw new Error("DATABASE_URL is not set \u2014 see .env.example.");
|
|
43
|
+
}
|
|
44
|
+
return new PrismaClient({ datasourceUrl: withPoolDefaults(databaseUrl) });
|
|
45
|
+
}
|
|
46
|
+
var prisma = globalForPrisma.prisma ?? createPrismaClient();
|
|
47
|
+
if (process.env.NODE_ENV !== "production") {
|
|
48
|
+
globalForPrisma.prisma = prisma;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// src/lib/interventions/digest.ts
|
|
52
|
+
var DEFAULT_DIGEST_INTERVAL_MS = 5 * 60 * 1e3;
|
|
53
|
+
var DEFAULT_SESSION_TTL_MS = 30 * 60 * 1e3;
|
|
54
|
+
function ridesDigest(finding) {
|
|
55
|
+
if (finding.timing !== "digest") return false;
|
|
56
|
+
if (finding.level === "nothing") return false;
|
|
57
|
+
return !isBlockingLevel(finding.level);
|
|
58
|
+
}
|
|
59
|
+
var DigestAccumulator = class {
|
|
60
|
+
pending = /* @__PURE__ */ new Map();
|
|
61
|
+
lastDelivered = /* @__PURE__ */ new Map();
|
|
62
|
+
/**
|
|
63
|
+
* When each session was last seen, for the TTL sweep.
|
|
64
|
+
*
|
|
65
|
+
* A third map rather than a timestamp derived from the other two, because
|
|
66
|
+
* neither can answer the question. `pending` is deleted outright by
|
|
67
|
+
* `take`, and `lastDelivered` is only written for a session that has
|
|
68
|
+
* actually had a batch — so a session that accumulated a few findings and
|
|
69
|
+
* left, or one whose batch was taken and never came back, is invisible to
|
|
70
|
+
* both while still holding a key in `lastDelivered`. `lastDelivered` is
|
|
71
|
+
* in fact the more persistent leak of the two: nothing removed a key from
|
|
72
|
+
* it, ever, including `forget`.
|
|
73
|
+
*/
|
|
74
|
+
lastSeen = /* @__PURE__ */ new Map();
|
|
75
|
+
intervalMs;
|
|
76
|
+
maxPending;
|
|
77
|
+
sessionTtlMs;
|
|
78
|
+
constructor(options = {}) {
|
|
79
|
+
this.intervalMs = options.intervalMs ?? DEFAULT_DIGEST_INTERVAL_MS;
|
|
80
|
+
this.sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
|
|
81
|
+
this.maxPending = options.maxPending ?? 50;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Holds a finding for the next batch. Returns whether it was held.
|
|
85
|
+
*
|
|
86
|
+
* `false` for a finding that does not ride the digest — the caller
|
|
87
|
+
* delivers that one itself — so a call site can route on one call rather
|
|
88
|
+
* than testing the timing and then adding it. `false` **also** when the
|
|
89
|
+
* buffer is full, for the same reason: the answer is "not held", and a
|
|
90
|
+
* caller that believed otherwise would drop it entirely.
|
|
91
|
+
*
|
|
92
|
+
* **Deduplicated by id.** The same entry triggering repeatedly within one
|
|
93
|
+
* window is one finding, and the *first* is kept rather than the last:
|
|
94
|
+
* the earliest observation is what makes the elapsed time in the batch
|
|
95
|
+
* honest. A finding that arrives twice with different data is still one
|
|
96
|
+
* entry saying one thing about one session.
|
|
97
|
+
*/
|
|
98
|
+
add(sessionId, finding, at) {
|
|
99
|
+
if (!ridesDigest(finding)) return false;
|
|
100
|
+
this.touch(sessionId, at);
|
|
101
|
+
this.sweep(at);
|
|
102
|
+
const existing = this.pending.get(sessionId) ?? [];
|
|
103
|
+
if (existing.some((held) => held.finding.id === finding.id)) return true;
|
|
104
|
+
if (existing.length >= this.maxPending) return false;
|
|
105
|
+
existing.push({ finding, at });
|
|
106
|
+
this.pending.set(sessionId, existing);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
/** How many findings are held for a session. */
|
|
110
|
+
pendingCount(sessionId) {
|
|
111
|
+
return this.pending.get(sessionId)?.length ?? 0;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Whether a batch is due for this session.
|
|
115
|
+
*
|
|
116
|
+
* Two conditions, both required: something is pending, and the interval
|
|
117
|
+
* has elapsed since the last delivery. A session that has never had one
|
|
118
|
+
* is measured from its earliest pending finding rather than from process
|
|
119
|
+
* start — otherwise the first digest of a long-lived process would be due
|
|
120
|
+
* instantly, reporting a single finding as though it were a batch, which
|
|
121
|
+
* is the drip this exists to avoid wearing a batch's name.
|
|
122
|
+
*/
|
|
123
|
+
isDue(sessionId, now) {
|
|
124
|
+
const held = this.pending.get(sessionId);
|
|
125
|
+
if (held === void 0 || held.length === 0) return false;
|
|
126
|
+
const last = this.lastDelivered.get(sessionId);
|
|
127
|
+
const since = last ?? Math.min(...held.map((entry) => entry.at));
|
|
128
|
+
return now - since >= this.intervalMs;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Takes the batch, clearing what it contained.
|
|
132
|
+
*
|
|
133
|
+
* `null` when nothing is due, so a caller asks once rather than testing
|
|
134
|
+
* and then taking — two calls between which the answer could change.
|
|
135
|
+
*
|
|
136
|
+
* Clearing on take is what stops a delivered finding being delivered
|
|
137
|
+
* again. It is re-detected on the next call that triggers it, which is
|
|
138
|
+
* the correct behaviour for a situation that is still true: it reappears
|
|
139
|
+
* in the *next* digest rather than being repeated in every one until
|
|
140
|
+
* somebody fixes it.
|
|
141
|
+
*/
|
|
142
|
+
take(sessionId, now) {
|
|
143
|
+
if (!this.isDue(sessionId, now)) return null;
|
|
144
|
+
const held = this.pending.get(sessionId);
|
|
145
|
+
if (held === void 0 || held.length === 0) return null;
|
|
146
|
+
this.pending.delete(sessionId);
|
|
147
|
+
this.lastDelivered.set(sessionId, now);
|
|
148
|
+
this.touch(sessionId, now);
|
|
149
|
+
return {
|
|
150
|
+
findings: held.map((entry) => entry.finding),
|
|
151
|
+
from: Math.min(...held.map((entry) => entry.at)),
|
|
152
|
+
to: now
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Drops everything held for a session that has ended.
|
|
157
|
+
*
|
|
158
|
+
* **All three maps, including `lastDelivered`.** An earlier version
|
|
159
|
+
* cleared only `pending`, reasoning that a session id that came back
|
|
160
|
+
* would otherwise be instantly due again. That reasoning does not survive
|
|
161
|
+
* contact with what a session id is: they are not reused, so the returning
|
|
162
|
+
* session it protected against does not exist, and the effect was a key
|
|
163
|
+
* that was never removed by anything — the leak this method appeared to
|
|
164
|
+
* be the answer to. The protection it was after is real but belongs to
|
|
165
|
+
* live sessions, and `isDue` already provides it by measuring from the
|
|
166
|
+
* earliest pending finding when there is no `lastDelivered` — so a
|
|
167
|
+
* genuinely returning id gets a fresh window rather than an instant batch.
|
|
168
|
+
*/
|
|
169
|
+
forget(sessionId) {
|
|
170
|
+
this.pending.delete(sessionId);
|
|
171
|
+
this.lastDelivered.delete(sessionId);
|
|
172
|
+
this.lastSeen.delete(sessionId);
|
|
173
|
+
}
|
|
174
|
+
/** Records that a session is active, for the TTL sweep. */
|
|
175
|
+
touch(sessionId, at) {
|
|
176
|
+
this.lastSeen.set(sessionId, at);
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Drops every session not seen within the TTL. Returns how many went.
|
|
180
|
+
*
|
|
181
|
+
* Called from `add` and `take` rather than from a timer, deliberately: a
|
|
182
|
+
* timer would keep a handle alive for the life of the process and would
|
|
183
|
+
* run in tests that never asked for it, and this module's whole contract
|
|
184
|
+
* is that time is an argument and nothing here reads a clock. Sweeping on
|
|
185
|
+
* activity means the map is tidied by the same traffic that grows it,
|
|
186
|
+
* and an idle process does no work — the case where its size is already
|
|
187
|
+
* not changing.
|
|
188
|
+
*
|
|
189
|
+
* A session is judged by `lastSeen` alone. Judging by the pending
|
|
190
|
+
* findings' own timestamps would miss exactly the sessions that leak:
|
|
191
|
+
* one whose batch was taken has no pending findings at all, yet still
|
|
192
|
+
* holds keys in `lastDelivered` and here.
|
|
193
|
+
*/
|
|
194
|
+
sweep(now) {
|
|
195
|
+
let dropped = 0;
|
|
196
|
+
for (const [sessionId, seen] of this.lastSeen) {
|
|
197
|
+
if (now - seen > this.sessionTtlMs) {
|
|
198
|
+
this.pending.delete(sessionId);
|
|
199
|
+
this.lastDelivered.delete(sessionId);
|
|
200
|
+
this.lastSeen.delete(sessionId);
|
|
201
|
+
dropped += 1;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return dropped;
|
|
205
|
+
}
|
|
206
|
+
/** How many sessions this accumulator holds. For tests and diagnostics. */
|
|
207
|
+
sessionCount() {
|
|
208
|
+
return this.lastSeen.size;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// src/lib/interventions/delivery.ts
|
|
213
|
+
function partitionFindings(findings) {
|
|
214
|
+
const immediate = [];
|
|
215
|
+
const deferred = [];
|
|
216
|
+
for (const finding of findings) {
|
|
217
|
+
if (ridesDigest(finding)) deferred.push(finding);
|
|
218
|
+
else immediate.push(finding);
|
|
219
|
+
}
|
|
220
|
+
return { immediate, deferred };
|
|
221
|
+
}
|
|
222
|
+
function decideDelivery(accumulator, options) {
|
|
223
|
+
const { immediate, deferred } = partitionFindings(options.findings ?? []);
|
|
224
|
+
const sessionId = options.sessionId;
|
|
225
|
+
if (sessionId === void 0) {
|
|
226
|
+
return immediate.length === 0 ? {} : { findings: immediate };
|
|
227
|
+
}
|
|
228
|
+
const nowDelivered = [...immediate];
|
|
229
|
+
for (const finding of deferred) {
|
|
230
|
+
if (!accumulator.add(sessionId, finding, options.now)) nowDelivered.push(finding);
|
|
231
|
+
}
|
|
232
|
+
const digest = accumulator.take(sessionId, options.now);
|
|
233
|
+
return {
|
|
234
|
+
...nowDelivered.length === 0 ? {} : { findings: nowDelivered },
|
|
235
|
+
...digest === null ? {} : { digest }
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function hasAnything(payload) {
|
|
239
|
+
const findings = payload.findings;
|
|
240
|
+
if (findings !== void 0 && findings.length > 0) return true;
|
|
241
|
+
const digest = payload.digest;
|
|
242
|
+
return digest !== void 0 && digest.findings.length > 0;
|
|
243
|
+
}
|
|
244
|
+
function attachInterventions(result, payload) {
|
|
245
|
+
if (!hasAnything(payload)) return result;
|
|
246
|
+
return { result, interventions: payload };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// src/lib/interventions/service-delivery.ts
|
|
250
|
+
function createServiceDeliverer(options = {}) {
|
|
251
|
+
const accumulator = options.accumulator ?? new DigestAccumulator();
|
|
252
|
+
const clock = options.now ?? Date.now;
|
|
253
|
+
const deliver = (result, caller) => {
|
|
254
|
+
const sessionId = caller.sessionId;
|
|
255
|
+
if (sessionId === void 0) return result;
|
|
256
|
+
const payload = decideDelivery(accumulator, { sessionId, now: clock() });
|
|
257
|
+
return attachInterventions(result, payload);
|
|
258
|
+
};
|
|
259
|
+
deliver.hold = (sessionId, findings, at) => {
|
|
260
|
+
const refused = [];
|
|
261
|
+
for (const finding of findings) {
|
|
262
|
+
if (!accumulator.add(sessionId, finding, at)) refused.push(finding);
|
|
263
|
+
}
|
|
264
|
+
return refused;
|
|
265
|
+
};
|
|
266
|
+
deliver.forget = (sessionId) => accumulator.forget(sessionId);
|
|
267
|
+
deliver.pendingCount = (sessionId) => accumulator.pendingCount(sessionId);
|
|
268
|
+
deliver.sessionCount = () => accumulator.sessionCount();
|
|
269
|
+
return deliver;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/lib/service/live.ts
|
|
273
|
+
var prismaSettingsSource = {
|
|
274
|
+
async readRevision() {
|
|
275
|
+
const rows = await prisma.$queryRawUnsafe(
|
|
276
|
+
`SELECT "revision" FROM "settings_revision" WHERE "id" = 1`
|
|
277
|
+
);
|
|
278
|
+
return rows[0]?.revision ?? 0n;
|
|
279
|
+
},
|
|
280
|
+
async readOverrides() {
|
|
281
|
+
return prisma.$transaction(async (tx) => {
|
|
282
|
+
const rows = await tx.$queryRawUnsafe(
|
|
283
|
+
`SELECT "key", "value" FROM "settings"`
|
|
284
|
+
);
|
|
285
|
+
const revisionRows = await tx.$queryRawUnsafe(
|
|
286
|
+
`SELECT "revision" FROM "settings_revision" WHERE "id" = 1`
|
|
287
|
+
);
|
|
288
|
+
return {
|
|
289
|
+
overrides: rows.map((row) => ({ key: row.key, value: row.value })),
|
|
290
|
+
revision: revisionRows[0]?.revision ?? 0n
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
var settingsCache = new SettingsCache({ source: prismaSettingsSource });
|
|
296
|
+
var SETTINGS_WRITE_OPERATIONS = /* @__PURE__ */ new Set([
|
|
297
|
+
"put_setting",
|
|
298
|
+
"delete_setting",
|
|
299
|
+
"patch_settings",
|
|
300
|
+
// Removing a stored override whose key this build does not declare is a
|
|
301
|
+
// settings change like any other: it bumps the revision, so the held
|
|
302
|
+
// snapshot is stale the moment it commits. This entry was added because
|
|
303
|
+
// the test below demanded it rather than because anyone remembered to —
|
|
304
|
+
// which is the whole reason that test derives the list from the source.
|
|
305
|
+
"remove_unrecognised_setting"
|
|
306
|
+
]);
|
|
307
|
+
var SettingsInvalidatingRuntime = class extends ServiceRuntime {
|
|
308
|
+
async call(name, input, options) {
|
|
309
|
+
const result = await super.call(name, input, options);
|
|
310
|
+
if (SETTINGS_WRITE_OPERATIONS.has(name)) settingsCache.invalidate();
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
};
|
|
314
|
+
var interventionDeliverer = createServiceDeliverer();
|
|
315
|
+
var service = new SettingsInvalidatingRuntime({
|
|
316
|
+
transaction: prismaTransactionRunner(prisma),
|
|
317
|
+
resolveSnapshot: () => settingsCache.get(),
|
|
318
|
+
deliverInterventions: interventionDeliverer
|
|
319
|
+
});
|
|
320
|
+
export {
|
|
321
|
+
SETTINGS_WRITE_OPERATIONS,
|
|
322
|
+
interventionDeliverer,
|
|
323
|
+
prismaSettingsSource,
|
|
324
|
+
service,
|
|
325
|
+
settingsCache
|
|
326
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"PreToolUse": [
|
|
3
|
+
{
|
|
4
|
+
"matcher": "*",
|
|
5
|
+
"hooks": [
|
|
6
|
+
{
|
|
7
|
+
"type": "command",
|
|
8
|
+
"command": "npx --no-install -p agent-standup standup-hook"
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
],
|
|
13
|
+
"PostToolUse": [
|
|
14
|
+
{
|
|
15
|
+
"matcher": "*",
|
|
16
|
+
"hooks": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "npx --no-install -p agent-standup standup-hook"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
],
|
|
24
|
+
"Stop": [
|
|
25
|
+
{
|
|
26
|
+
"matcher": "*",
|
|
27
|
+
"hooks": [
|
|
28
|
+
{
|
|
29
|
+
"type": "command",
|
|
30
|
+
"command": "npx --no-install -p agent-standup standup-hook"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: setup-agent-standup
|
|
3
|
+
description: Register this machine's Agent Standup poller and prove it works. Run it after installing the plugin, and re-run it any time to health-check the installation.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Set up Agent Standup on this machine
|
|
7
|
+
|
|
8
|
+
The plugin carries the MCP server, the hook and the command line. One thing it
|
|
9
|
+
cannot carry is the operating system's scheduler entry, because no plugin can
|
|
10
|
+
write one. This skill adds it — and then proves it.
|
|
11
|
+
|
|
12
|
+
## Registering is not the finish line
|
|
13
|
+
|
|
14
|
+
Verify, do not just install. A scheduled task can register successfully and
|
|
15
|
+
never fire: a principal that cannot attach to the logon session, a command that
|
|
16
|
+
does not resolve on this machine, an execution policy that refuses it. Every one
|
|
17
|
+
of those leaves a registered task, a success message, and a machine that polls
|
|
18
|
+
nothing. That is the state this skill exists to make impossible, so **do not
|
|
19
|
+
report success until a call has reached the server and the server has answered.**
|
|
20
|
+
|
|
21
|
+
## Steps
|
|
22
|
+
|
|
23
|
+
1. **Check the configuration.** `STANDUP_URL` must resolve, and this session
|
|
24
|
+
needs an id and a machine name. Without them there is nothing to verify
|
|
25
|
+
against, so nothing should be registered — a host changed by a run that could
|
|
26
|
+
never have succeeded is worse than a run that stopped early.
|
|
27
|
+
|
|
28
|
+
2. **Register the task** `AgentStandupPoller`, running `npx --no-install -p agent-standup standup sweep` every
|
|
29
|
+
5 minutes, with logon type `Interactive` and run
|
|
30
|
+
level `Limited`. That principal attaches to the existing logon session,
|
|
31
|
+
so it keeps firing while the machine is locked, and it needs no elevation and
|
|
32
|
+
stores no credential. The variant that survives a full logoff runs without a
|
|
33
|
+
desktop and is a different tool, not a safer one.
|
|
34
|
+
|
|
35
|
+
3. **Prove it.** Register this session with the server and read the reply. A
|
|
36
|
+
reply naming a hook variant and a protocol version is the proof; anything
|
|
37
|
+
else is not.
|
|
38
|
+
|
|
39
|
+
4. **Report what happened**, in these terms: the task name, the interval, and
|
|
40
|
+
whether a call reached the server. If registration succeeded and the proof did
|
|
41
|
+
not, say exactly that and leave the task in place — it may be correct and the
|
|
42
|
+
server merely unreachable, and re-running this skill is safe.
|
|
43
|
+
|
|
44
|
+
## Re-running it is the health check
|
|
45
|
+
|
|
46
|
+
Registration is idempotent and the proof is a live call, so running this a
|
|
47
|
+
second time answers "is this machine still wired up" without changing anything
|
|
48
|
+
that was already right.
|
|
49
|
+
|
|
50
|
+
## Removing it
|
|
51
|
+
|
|
52
|
+
Unregister the task named `AgentStandupPoller`. Nothing else on the machine is
|
|
53
|
+
changed by this skill; the plugin itself is removed the way it was installed.
|