@caph42/pi-ccline 0.0.0-stage → 0.1.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.
@@ -0,0 +1,104 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export interface PiCclineConfig {
6
+ /** Whether the footer is installed at session start. */
7
+ enabled: boolean;
8
+ /** Explicit ccline binary path; null means use the default resolution order. */
9
+ binary: string | null;
10
+ /** Hard timeout for one ccline invocation. */
11
+ timeoutMs: number;
12
+ /** Minimum spacing between two refreshes. Never below MIN_THROTTLE_MS. */
13
+ refreshThrottleMs: number;
14
+ /**
15
+ * Interval of the live ticker that keeps time-based segments (elapsed
16
+ * duration) moving while the agent runs. 0 disables the ticker.
17
+ */
18
+ liveRefreshMs: number;
19
+ /** Append the model's provider to the model segment of the footer. */
20
+ showProvider: boolean;
21
+ /** Extra environment variables passed to the binary. */
22
+ env: Record<string, string>;
23
+ }
24
+
25
+ export const DEFAULT_CONFIG: PiCclineConfig = {
26
+ enabled: true,
27
+ binary: null,
28
+ timeoutMs: 5000,
29
+ refreshThrottleMs: 1000,
30
+ liveRefreshMs: 1000,
31
+ showProvider: true,
32
+ env: {},
33
+ };
34
+
35
+ export const MIN_TIMEOUT_MS = 200;
36
+ export const MAX_TIMEOUT_MS = 60_000;
37
+ /** The footer re-renders often; a faster refresh rate would spawn too many processes. */
38
+ export const MIN_THROTTLE_MS = 1000;
39
+ /** Below this a live ticker would spawn processes faster than a statusline is read. */
40
+ export const MIN_LIVE_REFRESH_MS = 250;
41
+
42
+ /** Path of the extension's own config file (not pi's settings.json). */
43
+ export function configPath(home: string = homedir()): string {
44
+ return join(home, ".pi", "agent", "pi-ccline", "config.json");
45
+ }
46
+
47
+ function clampInt(value: unknown, fallback: number, min: number, max: number): number {
48
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
49
+ return Math.min(max, Math.max(min, Math.round(value)));
50
+ }
51
+
52
+ /** Coerce arbitrary parsed JSON into a valid config. Never throws. */
53
+ export function normalizeConfig(raw: unknown): PiCclineConfig {
54
+ const source: Record<string, unknown> = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
55
+
56
+ const env: Record<string, string> = {};
57
+ const rawEnv = source.env;
58
+ if (rawEnv && typeof rawEnv === "object" && !Array.isArray(rawEnv)) {
59
+ for (const [key, value] of Object.entries(rawEnv as Record<string, unknown>)) {
60
+ if (typeof value === "string") env[key] = value;
61
+ }
62
+ }
63
+
64
+ /** 0 is an explicit opt-out; anything else is clamped to a sane tick range. */
65
+ function clampLiveRefresh(value: unknown, fallback: number): number {
66
+ if (value === 0) return 0;
67
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
68
+ return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_LIVE_REFRESH_MS, Math.round(value)));
69
+ }
70
+
71
+ const binary = typeof source.binary === "string" && source.binary.trim() ? source.binary.trim() : null;
72
+
73
+ return {
74
+ enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.enabled,
75
+ binary,
76
+ timeoutMs: clampInt(source.timeoutMs, DEFAULT_CONFIG.timeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
77
+ refreshThrottleMs: clampInt(source.refreshThrottleMs, DEFAULT_CONFIG.refreshThrottleMs, MIN_THROTTLE_MS, MAX_TIMEOUT_MS),
78
+ liveRefreshMs: clampLiveRefresh(source.liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs),
79
+ showProvider: typeof source.showProvider === "boolean" ? source.showProvider : DEFAULT_CONFIG.showProvider,
80
+ env,
81
+ };
82
+ }
83
+
84
+ /** Read the config file. A missing or broken file falls back to defaults. */
85
+ export function loadConfig(path: string = configPath()): PiCclineConfig {
86
+ try {
87
+ return normalizeConfig(JSON.parse(readFileSync(path, "utf8")));
88
+ } catch {
89
+ return normalizeConfig(DEFAULT_CONFIG);
90
+ }
91
+ }
92
+
93
+ /** Persist the config. Returns false instead of throwing on any I/O error. */
94
+ export function saveConfig(config: PiCclineConfig, path: string = configPath()): boolean {
95
+ try {
96
+ mkdirSync(dirname(path), { recursive: true });
97
+ const temp = `${path}.${process.pid}.tmp`;
98
+ writeFileSync(temp, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, "utf8");
99
+ renameSync(temp, path);
100
+ return true;
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
@@ -0,0 +1,411 @@
1
+ import assert from "node:assert/strict";
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, test } from "node:test";
6
+
7
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+
9
+ import { clearBinaryCache } from "./binary.ts";
10
+ import { DEFAULT_CONFIG, type PiCclineConfig } from "./config.ts";
11
+ import { CclineFooter, default as piCcline } from "./index.ts";
12
+
13
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ /** Every temp directory made below is removed even when an assertion fails. */
16
+ const createdDirs: string[] = [];
17
+ /** Footers created by attach(); disposed after each test so no interval survives a failure. */
18
+ const createdFooters: { dispose(): void }[] = [];
19
+ afterEach(() => {
20
+ for (const footer of createdFooters.splice(0)) footer.dispose();
21
+ for (const dir of createdDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
22
+ });
23
+
24
+ function tempDir(prefix: string): string {
25
+ const dir = mkdtempSync(join(tmpdir(), prefix));
26
+ createdDirs.push(dir);
27
+ return dir;
28
+ }
29
+
30
+ /** Poll instead of sleeping a fixed amount; the machine may be busy. */
31
+ async function waitFor(check: () => boolean, timeoutMs = 8000): Promise<void> {
32
+ const deadline = Date.now() + timeoutMs;
33
+ while (Date.now() < deadline) {
34
+ if (check()) return;
35
+ await sleep(25);
36
+ }
37
+ throw new Error("timed out waiting for condition");
38
+ }
39
+
40
+ /** Spawn counter kept in a file by the fake binaries; 0 while the file is unwritten. */
41
+ function countSpawns(path: string): number {
42
+ try {
43
+ return Number(readFileSync(path, "utf8")) || 0;
44
+ } catch {
45
+ return 0;
46
+ }
47
+ }
48
+
49
+ function fakeBinary(script: string): string {
50
+ const path = join(tempDir("pi-ccline-footer-"), "fake-ccline");
51
+ writeFileSync(path, `#!/bin/sh\n${script}\n`, "utf8");
52
+ chmodSync(path, 0o755);
53
+ return path;
54
+ }
55
+
56
+ function fakeContext(): ExtensionContext {
57
+ return {
58
+ cwd: "/work/project",
59
+ mode: "tui",
60
+ hasUI: true,
61
+ model: { id: "test-model", name: "Test Model", contextWindow: 100_000 },
62
+ thinkingLevel: "high",
63
+ getContextUsage: () => ({ tokens: 5000, contextWindow: 100_000, percent: 5 }),
64
+ sessionManager: {
65
+ getBranch: () => [
66
+ {
67
+ type: "message",
68
+ timestamp: new Date().toISOString(),
69
+ message: { role: "assistant", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } } },
70
+ },
71
+ ],
72
+ getSessionId: () => "sess-1",
73
+ getSessionName: () => "footer test",
74
+ },
75
+ } as unknown as ExtensionContext;
76
+ }
77
+
78
+ interface Harness {
79
+ footer: CclineFooter;
80
+ renders: () => number;
81
+ render: (width: number) => string[];
82
+ }
83
+
84
+ function attach(
85
+ config: Partial<PiCclineConfig>,
86
+ footer = new CclineFooter(fakeContext(), { ...DEFAULT_CONFIG, ...config }),
87
+ statuses = new Map<string, string>(),
88
+ ): Harness {
89
+ clearBinaryCache();
90
+ createdFooters.push(footer);
91
+ let renders = 0;
92
+ const component = footer.attach(
93
+ { requestRender: () => { renders += 1; } },
94
+ { fg: (_color: string, text: string) => `\u001b[2m${text}\u001b[0m` },
95
+ { onBranchChange: () => () => {}, getExtensionStatuses: () => statuses },
96
+ );
97
+ return { footer, renders: () => renders, render: (width) => component.render(width) };
98
+ }
99
+
100
+ test("renders a loading notice before the first frame arrives", () => {
101
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
102
+ const harness = attach({ binary });
103
+ const first = harness.render(80);
104
+ assert.equal(first.length, 1);
105
+ assert.match(first[0] ?? "", /ccline/);
106
+ assert.equal(harness.footer.hasFrame, false, "render() must not run the binary inline");
107
+ harness.footer.dispose();
108
+ });
109
+
110
+ test("shows the ccline output once the deferred refresh completes", async () => {
111
+ const binary = fakeBinary("cat > /dev/null; printf '\\033[32mFAKE-CCLINE\\033[0m\\n'");
112
+ const harness = attach({ binary });
113
+ harness.render(80);
114
+ await waitFor(() => harness.footer.hasFrame);
115
+ const lines = harness.render(80);
116
+ assert.equal(lines.length, 1);
117
+ assert.match(lines[0] ?? "", /FAKE-CCLINE/);
118
+ assert.equal(harness.footer.hasFrame, true);
119
+ assert.ok(harness.renders() > 0, "the footer asked for a repaint");
120
+ harness.footer.dispose();
121
+ });
122
+
123
+ test("refreshes for the width the footer was rendered at", async () => {
124
+ const binary = fakeBinary('cat > /dev/null; printf "w=%s\\n" "$COLUMNS"');
125
+ const harness = attach({ binary });
126
+ harness.render(60);
127
+ await waitFor(() => harness.footer.hasFrame);
128
+ assert.match(harness.render(60)[0] ?? "", /w=60/);
129
+
130
+ // A width change is picked up by the throttled scheduler, not by render().
131
+ harness.render(100);
132
+ await waitFor(() => /w=100/.test(harness.render(100)[0] ?? ""));
133
+ harness.footer.dispose();
134
+ });
135
+
136
+ test("keeps the last frame and marks it stale when a refresh fails", async () => {
137
+ const directory = tempDir("pi-ccline-flaky-");
138
+ const counter = join(directory, "count");
139
+ const binary = fakeBinary(
140
+ [
141
+ "cat > /dev/null",
142
+ `n=0; [ -f "${counter}" ] && n=$(cat "${counter}")`,
143
+ "n=$((n+1))",
144
+ `printf '%s' "$n" > "${counter}"`,
145
+ `if [ "$n" -eq 1 ]; then printf 'GOOD\\n'; exit 0; fi`,
146
+ "printf 'nope\\n' >&2",
147
+ "exit 7",
148
+ ].join("\n"),
149
+ );
150
+
151
+ const harness = attach({ binary });
152
+ harness.render(40);
153
+ await waitFor(() => /GOOD/.test(harness.render(40)[0] ?? ""));
154
+
155
+ harness.footer.requestRefresh();
156
+ await waitFor(() => /stale/.test(harness.render(40)[0] ?? ""));
157
+ const lines = harness.render(40);
158
+ assert.match(lines[0] ?? "", /GOOD/, "the last good frame is kept");
159
+ assert.match(lines[0] ?? "", /stale/);
160
+ assert.match(harness.footer.error ?? "", /code 7/);
161
+ harness.footer.dispose();
162
+ });
163
+
164
+ test("shows a failure notice instead of a blank footer when ccline always fails", async () => {
165
+ const binary = fakeBinary("cat > /dev/null; printf 'broken\n' >&2; exit 9");
166
+ const harness = attach({ binary });
167
+ harness.render(60);
168
+ await waitFor(() => harness.footer.hasFrame);
169
+ const lines = harness.render(60);
170
+ assert.match(lines[0] ?? "", /ccline failed: exited with code 9: broken/);
171
+ assert.equal(harness.footer.hasFrame, true);
172
+ harness.footer.dispose();
173
+ });
174
+
175
+ test("shows extension statuses on an extra row below the ccline frame", async () => {
176
+ const binary = fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'");
177
+ const statuses = new Map([["other-plugin", "Other status"]]);
178
+ const harness = attach({ binary }, undefined, statuses);
179
+ harness.render(80);
180
+ await waitFor(() => harness.footer.hasFrame);
181
+ assert.deepEqual(harness.render(80), ["FAKE-CCLINE", "Other status"]);
182
+
183
+ // pi repaints on setStatus, so a later change needs no refresh of the frame.
184
+ statuses.set("cursor-fast", "Cursor fast: on");
185
+ assert.deepEqual(harness.render(80), ["FAKE-CCLINE", "Cursor fast: on Other status"]);
186
+ statuses.clear();
187
+ assert.deepEqual(harness.render(80), ["FAKE-CCLINE"]);
188
+ harness.footer.dispose();
189
+ });
190
+
191
+ test("shows extension statuses even before ccline has produced a frame", () => {
192
+ const statuses = new Map([["cursor-fast", "Cursor fast: off"]]);
193
+ const harness = attach({ binary: fakeBinary("cat > /dev/null; printf 'LATE\\n'") }, undefined, statuses);
194
+ const lines = harness.render(80);
195
+ assert.equal(lines.length, 2);
196
+ assert.match(lines[0] ?? "", /ccline/);
197
+ assert.equal(lines[1], "Cursor fast: off");
198
+ harness.footer.dispose();
199
+ });
200
+
201
+ test("live ticking advances time-based segments and stops with the agent run", async () => {
202
+ const counter = join(tempDir("pi-ccline-live-"), "count");
203
+ const binary = fakeBinary(
204
+ [
205
+ "cat > /dev/null",
206
+ `printf '%s' "$(( $(cat "${counter}" 2>/dev/null || echo 0) + 1 ))" > "${counter}"`,
207
+ "printf 'LIVE\\n'",
208
+ ].join("\n"),
209
+ );
210
+
211
+ const harness = attach({ binary, liveRefreshMs: 250 });
212
+ harness.render(40);
213
+ await waitFor(() => harness.footer.hasFrame);
214
+ const afterFirstFrame = countSpawns(counter);
215
+
216
+ harness.footer.startLive();
217
+ await waitFor(() => countSpawns(counter) >= afterFirstFrame + 2);
218
+
219
+ harness.footer.stopLive();
220
+ // Stopping only clears the interval: one already-queued refresh may still land.
221
+ await sleep(1400);
222
+ const drained = countSpawns(counter);
223
+ await sleep(1300);
224
+ assert.equal(countSpawns(counter), drained, "no periodic refresh after stopLive");
225
+ });
226
+
227
+ test("dispose stops the ticker even while a refresh is queued", async () => {
228
+ const counter = join(tempDir("pi-ccline-live-dispose-"), "count");
229
+ const binary = fakeBinary(
230
+ [
231
+ "cat > /dev/null",
232
+ `printf '%s' "$(( $(cat "${counter}" 2>/dev/null || echo 0) + 1 ))" > "${counter}"`,
233
+ "printf 'LIVE\\n'",
234
+ ].join("\n"),
235
+ );
236
+
237
+ const harness = attach({ binary, liveRefreshMs: 250 });
238
+ harness.render(40);
239
+ await waitFor(() => harness.footer.hasFrame);
240
+ harness.footer.startLive();
241
+ await waitFor(() => countSpawns(counter) >= 2);
242
+
243
+ harness.footer.requestRefresh();
244
+ harness.footer.dispose();
245
+ const atDispose = countSpawns(counter);
246
+ await sleep(1400);
247
+ assert.equal(countSpawns(counter), atDispose, "nothing runs after dispose");
248
+ assert.equal(harness.footer.hasFrame, true, "the last frame is kept");
249
+ });
250
+
251
+ test("dispose stops refreshes, keeps the last frame and spawns nothing new", async () => {
252
+ const counter = join(tempDir("pi-ccline-dispose-"), "count");
253
+ const binary = fakeBinary(
254
+ [
255
+ "cat > /dev/null",
256
+ `n=0; [ -f "${counter}" ] && n=$(cat "${counter}")`,
257
+ `printf '%s' "$((n+1))" > "${counter}"`,
258
+ "printf 'STABLE\\n'",
259
+ ].join("\n"),
260
+ );
261
+
262
+ const harness = attach({ binary });
263
+ harness.render(40);
264
+ await waitFor(() => harness.footer.hasFrame);
265
+ const before = harness.render(40);
266
+ const spawnsBefore = Number(readFileSync(counter, "utf8"));
267
+ const rendersBefore = harness.renders();
268
+
269
+ harness.footer.dispose();
270
+ harness.footer.requestRefresh();
271
+ await sleep(1300);
272
+ harness.render(40);
273
+
274
+ assert.deepEqual(harness.render(40), before);
275
+ assert.equal(Number(readFileSync(counter, "utf8")), spawnsBefore, "no spawn after dispose");
276
+ assert.equal(harness.renders(), rendersBefore, "no repaint request after dispose");
277
+ });
278
+
279
+ test("dispose aborts an in-flight child and never paints its result", async () => {
280
+ const directory = tempDir("pi-ccline-inflight-");
281
+ const counter = join(directory, "count");
282
+ const binary = fakeBinary(
283
+ [
284
+ "cat > /dev/null",
285
+ `printf '%s' "$(( $(cat "${counter}" 2>/dev/null || echo 0) + 1 ))" > "${counter}"`,
286
+ "sleep 10",
287
+ "printf 'LATE\\n'",
288
+ ].join("\n"),
289
+ );
290
+
291
+ const harness = attach({ binary });
292
+ harness.render(40);
293
+ await waitFor(() => {
294
+ try {
295
+ return readFileSync(counter, "utf8") === "1";
296
+ } catch {
297
+ return false;
298
+ }
299
+ });
300
+ const rendersBefore = harness.renders();
301
+ const startedAt = Date.now();
302
+ harness.footer.dispose();
303
+ await sleep(300);
304
+
305
+ assert.equal(harness.footer.hasFrame, false, "aborted result must not be painted");
306
+ assert.equal(harness.renders(), rendersBefore, "no repaint for an aborted run");
307
+ assert.ok(Date.now() - startedAt < 5000, "abort must not wait for the child");
308
+ assert.equal(readFileSync(counter, "utf8"), "1", "the child was started exactly once");
309
+ });
310
+
311
+ /** Records what the extension registers so the real lifecycle wiring can be driven. */
312
+ function fakePi() {
313
+ const handlers = new Map<string, (event: unknown, ctx: unknown) => unknown>();
314
+ const pi = {
315
+ on: (name: string, handler: (event: unknown, ctx: unknown) => unknown) => {
316
+ handlers.set(name, handler);
317
+ return () => handlers.delete(name);
318
+ },
319
+ registerCommand: () => {},
320
+ };
321
+ const dispatch = (name: string, ctx: unknown) => handlers.get(name)?.({ type: name }, ctx);
322
+ return { pi, handlers, dispatch };
323
+ }
324
+
325
+ /** Session context whose footer factory captures the component pi would render. */
326
+ function fakeSession(options: { idle: boolean; binary: string }) {
327
+ const captured: { component: { render(width: number): string[] } | null } = { component: null };
328
+ const ctx = {
329
+ ...fakeContext(),
330
+ isIdle: () => options.idle,
331
+ ui: {
332
+ setFooter: (factory: (tui: unknown, theme: unknown, footerData: unknown) => { render(width: number): string[] } | undefined) => {
333
+ captured.component = factory
334
+ ? factory(
335
+ { requestRender: () => {} },
336
+ { fg: (_color: string, text: string) => text },
337
+ { onBranchChange: () => () => {}, getExtensionStatuses: () => new Map() },
338
+ )
339
+ : null;
340
+ },
341
+ notify: () => {},
342
+ },
343
+ } as unknown as ExtensionContext;
344
+ const loadConfig = () => ({ ...DEFAULT_CONFIG, binary: options.binary, liveRefreshMs: 250 });
345
+ return { ctx, captured, loadConfig };
346
+ }
347
+
348
+ test("agent_start ticks the footer and agent_end leaves no periodic refresh behind", async () => {
349
+ const counter = join(tempDir("pi-ccline-lifecycle-"), "count");
350
+ const binary = fakeBinary(
351
+ [
352
+ "cat > /dev/null",
353
+ `printf '%s' "$(( $(cat "${counter}" 2>/dev/null || echo 0) + 1 ))" > "${counter}"`,
354
+ "printf 'LIVE\\n'",
355
+ ].join("\n"),
356
+ );
357
+ const { pi, handlers, dispatch } = fakePi();
358
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary, liveRefreshMs: 250 }) });
359
+
360
+ for (const event of ["session_start", "turn_start", "turn_end", "agent_start", "agent_end", "agent_settled", "session_shutdown"]) {
361
+ assert.ok(handlers.has(event), `${event} is wired`);
362
+ }
363
+
364
+ const { ctx, captured } = fakeSession({ idle: true, binary });
365
+ await dispatch("session_start", ctx);
366
+ assert.ok(captured.component, "the footer was installed");
367
+ captured.component.render(60);
368
+ await waitFor(() => countSpawns(counter) >= 1);
369
+
370
+ const idleBaseline = countSpawns(counter);
371
+ await sleep(700);
372
+ assert.equal(countSpawns(counter), idleBaseline, "an idle session must not tick");
373
+
374
+ await dispatch("agent_start", ctx);
375
+ await waitFor(() => countSpawns(counter) >= idleBaseline + 2);
376
+
377
+ await dispatch("agent_end", ctx);
378
+ await sleep(1400); // let the queued final refresh land
379
+ const drained = countSpawns(counter);
380
+ await sleep(1300);
381
+ assert.equal(countSpawns(counter), drained, "no periodic refresh once the run ended");
382
+
383
+ await dispatch("session_shutdown", ctx);
384
+ });
385
+
386
+ test("a footer installed mid-run ticks without waiting for the next turn", async () => {
387
+ const counter = join(tempDir("pi-ccline-lifecycle-midrun-"), "count");
388
+ const binary = fakeBinary(
389
+ [
390
+ "cat > /dev/null",
391
+ `printf '%s' "$(( $(cat "${counter}" 2>/dev/null || echo 0) + 1 ))" > "${counter}"`,
392
+ "printf 'LIVE\\n'",
393
+ ].join("\n"),
394
+ );
395
+ const { pi, dispatch } = fakePi();
396
+ piCcline(pi as never, { loadConfig: () => ({ ...DEFAULT_CONFIG, binary, liveRefreshMs: 250 }) });
397
+
398
+ // The agent is already streaming when the session starts, so no agent_start
399
+ // will arrive until the next run.
400
+ const { ctx, captured } = fakeSession({ idle: false, binary });
401
+ await dispatch("session_start", ctx);
402
+ captured.component?.render(60);
403
+ const installed = countSpawns(counter);
404
+ await waitFor(() => countSpawns(counter) >= installed + 2);
405
+
406
+ await dispatch("session_shutdown", ctx);
407
+ await sleep(1400);
408
+ const afterShutdown = countSpawns(counter);
409
+ await sleep(1000);
410
+ assert.equal(countSpawns(counter), afterShutdown, "shutdown stops the ticker");
411
+ });