@maheidem/pi-loop 0.7.0 → 0.7.1

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/index.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from "./state.ts";
33
33
  import { buildLoopPanelSnapshot, formatCountdown } from "./ui/loop-panel.ts";
34
34
  import { SettingsPanel, type PanelResult } from "./ui/settings-panel.ts";
35
+ import { loopVersion } from "./version.ts";
35
36
 
36
37
  const TAG = "[loop]";
37
38
  const TICK_CUSTOM_TYPE = "loop-tick";
@@ -83,6 +84,7 @@ export default function (pi: ExtensionAPI) {
83
84
  const state = rt.state;
84
85
  return [
85
86
  `${TAG} active`,
87
+ `version: v${loopVersion()}`,
86
88
  `interval: ${formatInterval(state.intervalMs)}`,
87
89
  `next tick: ${formatCountdown(rt.nextFireAt - Date.now())}`,
88
90
  `ticks sent: ${state.tickCount}`,
@@ -194,7 +196,7 @@ export default function (pi: ExtensionAPI) {
194
196
  if (raw === undefined) return false;
195
197
  const parsed = parseInterval(raw.trim());
196
198
  if (Number.isNaN(parsed)) {
197
- say(ctx, "Interval must look like 30s, 10m, 2h, or 1d.", "error");
199
+ say(ctx, `${TAG} Interval must look like 30s, 10m, 2h, or 1d.`, "error");
198
200
  proposed = raw;
199
201
  continue;
200
202
  }
@@ -205,7 +207,7 @@ export default function (pi: ExtensionAPI) {
205
207
  if (editedPrompt === undefined) return false;
206
208
  const prompt = editedPrompt.trim();
207
209
  if (!prompt) {
208
- say(ctx, "Recurring prompt cannot be empty.", "error");
210
+ say(ctx, `${TAG} Recurring prompt cannot be empty.`, "error");
209
211
  return false;
210
212
  }
211
213
 
@@ -308,9 +310,10 @@ export default function (pi: ExtensionAPI) {
308
310
  });
309
311
 
310
312
  pi.registerCommand("loop", {
311
- description: "Open recurring-loop controls or schedule with /loop <interval> <prompt...>",
313
+ description:
314
+ "Open recurring-loop controls or schedule with /loop <interval> <prompt...>. Verbs: status (scriptable status), edit (TUI-only interval/prompt editor), stop.",
312
315
  getArgumentCompletions: (prefix) => {
313
- const values = ["status", "stop", "30s ", "5m ", "10m ", "1h "];
316
+ const values = ["status", "edit", "stop", "30s ", "5m ", "10m ", "1h "];
314
317
  const matches = values.filter((value) => value.startsWith(prefix));
315
318
  return matches.length ? matches.map((value) => ({ value, label: value })) : null;
316
319
  },
@@ -328,6 +331,14 @@ export default function (pi: ExtensionAPI) {
328
331
  stopLoop(ctx, "user requested");
329
332
  return;
330
333
  }
334
+ if (a === "edit") {
335
+ if (ctx.mode !== "tui") {
336
+ say(ctx, `${TAG} /loop edit opens the TUI interval/prompt editor and is panel/TUI-only — in print mode re-arm instead: /loop stop then /loop <interval> <prompt...>. /loop status shows the current schedule.`);
337
+ return;
338
+ }
339
+ await editSchedule(ctx, true);
340
+ return;
341
+ }
331
342
  const parsed = parseArgs(a);
332
343
  if (typeof parsed === "string") {
333
344
  say(ctx, `${TAG} ${parsed}`, "error");
@@ -357,6 +368,12 @@ export default function (pi: ExtensionAPI) {
357
368
  interval: Type.Optional(Type.String({ description: "e.g. 30s, 5m, 2h, 1d. Default 10m, min 15s." })),
358
369
  prompt: Type.Optional(Type.String({ description: "The prompt to fire each tick, verbatim. Required for action=start." })),
359
370
  }),
371
+ promptGuidelines: [
372
+ "Use this tool for unattended recurring work at a fixed cadence — the prompt re-fires every interval without anyone typing it.",
373
+ "The prompt fires verbatim on each tick, prefixed with a one-line provenance header ([loop tick #N · scheduled by /loop, not typed by the user]).",
374
+ "The interval is fixed at arm time; to change it, stop (action: stop) and re-arm with the new interval.",
375
+ "Stop the loop once the goal is reached — leftover loops keep firing until stopped or the session ends.",
376
+ ],
360
377
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
361
378
  if (params.action === "start") {
362
379
  const parsed = parseArgs(`${params.interval ?? ""} ${params.prompt ?? ""}`.trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maheidem/pi-loop",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /loop \u2014 a generic recurring-prompt loop (Claude Code /loop style) delivered as in-session steer custom messages. Skills (e.g. shepherd) are consumers of /loop, not part of it.",
6
6
  "keywords": [
@@ -13,8 +13,10 @@
13
13
  "files": [
14
14
  "index.ts",
15
15
  "state.ts",
16
+ "version.ts",
16
17
  "ui/settings-panel.ts",
17
18
  "ui/loop-panel.ts",
19
+ "ui/version.ts",
18
20
  "skills/",
19
21
  "README.md",
20
22
  "LICENSE"
package/ui/version.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ui/version.ts — canonical loaded-version provenance (S2, pi-panel-kit
3
+ * stage 1).
4
+ *
5
+ * WHY THIS EXISTS
6
+ * A running Pi session keeps whatever extension code it loaded at startup;
7
+ * newer installs (npm store or by-path) only apply after `/reload`. A stale
8
+ * in-process copy is indistinguishable from a fresh one unless every status
9
+ * line, panel summary, and tool card carries the version actually EXECUTING.
10
+ * This helper reads that version from the extension's own `package.json` —
11
+ * never hard-coded, never baked at build time.
12
+ *
13
+ * Vendored byte-identically into consumers (guard: `node
14
+ * skills/pi-extension-builder/scripts/check-vendored.mjs`); each extension
15
+ * keeps a thin wrapper (`version.ts` at its package root) that exports its
16
+ * original function name and passes the `package.json` path resolved from
17
+ * THE WRAPPER'S module URL — a vendored copy sits in `ui/` and cannot find
18
+ * the package root by itself, which is exactly why the path is a parameter.
19
+ *
20
+ * Pi-free by design: node built-ins only, no imports from sibling modules.
21
+ */
22
+
23
+ import * as fs from "node:fs";
24
+
25
+ export interface ExtensionVersionOptions {
26
+ /**
27
+ * Absolute path to the extension's `package.json`. Resolve it from the
28
+ * wrapper's own module URL (`path.join(dirname(fileURLToPath(import.meta.url)), "package.json")`)
29
+ * so the string is anchored to the code actually executing, not the cwd.
30
+ */
31
+ packageJsonPath: string;
32
+ /**
33
+ * Short extension name for composed headers. When set, the result is
34
+ * `label vX.Y.Z` (the `[delegate v0.3.2 …]` pattern used in result
35
+ * headers). Default: no label, bare version (status lines and panel
36
+ * summaries render their own prefixes).
37
+ */
38
+ label?: string;
39
+ /**
40
+ * Verbatim suffix appended after the version, e.g.
41
+ * `" (loaded at session start; /reload picks up newer installs)"`.
42
+ * Default: none. Call sites that interpolate the suffix themselves keep
43
+ * passing nothing — identical output either way.
44
+ */
45
+ suffix?: string;
46
+ /** Returned when `package.json` is unreadable/unparsable or lacks `version`. Default `"unknown"`. */
47
+ fallback?: string;
48
+ }
49
+
50
+ /** Raw versions keyed by resolved package.json path; read once per process. */
51
+ const cache = new Map<string, string>();
52
+
53
+ /** The `version` field of the extension's package.json (never a hardcoded string). */
54
+ export function extensionVersion(options: ExtensionVersionOptions): string {
55
+ const fallback = options.fallback ?? "unknown";
56
+ let version = cache.get(options.packageJsonPath);
57
+ if (version === undefined) {
58
+ try {
59
+ const pkg = JSON.parse(fs.readFileSync(options.packageJsonPath, "utf8")) as {
60
+ version?: string;
61
+ };
62
+ version = pkg.version ?? fallback;
63
+ } catch {
64
+ version = fallback;
65
+ }
66
+ cache.set(options.packageJsonPath, version);
67
+ }
68
+ return `${options.label ? `${options.label} v` : ""}${version}${options.suffix ?? ""}`;
69
+ }
package/version.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * loop — loaded-version provenance.
3
+ *
4
+ * A running Pi session keeps whatever extension code it loaded at startup;
5
+ * newer npm-store installs only apply after /reload. The /loop status
6
+ * output therefore carries the version actually executing, so a stale copy
7
+ * in this process is self-evident instead of mysterious.
8
+ *
9
+ * Thin wrapper over the canonical helper vendored at `ui/version.ts`
10
+ * (kit source: `skills/pi-extension-builder/assets/control-panel/…`); the
11
+ * package.json path is resolved from THIS module's URL.
12
+ */
13
+
14
+ import * as path from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ import { extensionVersion } from "./ui/version.ts";
18
+
19
+ export function loopVersion(): string {
20
+ return extensionVersion({
21
+ packageJsonPath: path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json"),
22
+ suffix: " (loaded at session start; /reload picks up newer installs)",
23
+ });
24
+ }