pi-creel 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-creel",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "request_secret for the pi coding agent: capture an API key into a local .env via a tmux popup (the creel tool) so it never enters the chat/context.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -182,11 +182,15 @@ function defaultResolveSessionId(tmux: TmuxContext): string | undefined {
182
182
 
183
183
  // startCreelWatch notifies THIS pi session when the user saves a secret via
184
184
  // creel outside a request_secret call (the tmux keybind runs creel with
185
- // --event-file <dir>/<session>.json). It resolves the session id so a save in
186
- // one session never notifies another (routing by tmux session), watches the
187
- // events dir, and on a NEW event delivers a value-free note - steering a fresh
188
- // turn when idle, queuing after the current turn when busy (mirroring
189
- // pi-wakeup). It is a no-op outside tmux, where there is no keybind to hear.
185
+ // --event-file <root>/<socket>/<session>.json). Routing must be GLOBALLY
186
+ // unique: a tmux session id (`$0`, `$1`...) is only unique within one tmux
187
+ // server, and we run one socket per project against a shared events root, so
188
+ // the key is scoped by socket name first, session id second. Without this a
189
+ // save on one socket wakes a same-numbered session on every other socket. It
190
+ // watches this socket's subdir, and on a NEW event delivers a value-free note -
191
+ // steering a fresh turn when idle, queuing after the current turn when busy
192
+ // (mirroring pi-wakeup). It is a no-op outside tmux, where there is no keybind
193
+ // to hear.
190
194
  export function startCreelWatch(pi: any, deps: Deps = {}): void {
191
195
  const resolveT = deps.resolveTmux ?? resolveTmux;
192
196
  const resolveSid = deps.resolveSessionId ?? defaultResolveSessionId;
@@ -201,7 +205,9 @@ export function startCreelWatch(pi: any, deps: Deps = {}): void {
201
205
  const sid = resolveSid(tmux);
202
206
  if (!sid) return;
203
207
 
204
- const dir = join(home, EVENTS_SUBDIR);
208
+ // Scope by socket so `$0` on proj-foo and `$0` on proj-bar never collide in
209
+ // the shared events root.
210
+ const dir = join(home, EVENTS_SUBDIR, tmux.socket);
205
211
  try {
206
212
  ensureDir(dir);
207
213
  } catch {
@@ -218,7 +224,16 @@ export function startCreelWatch(pi: any, deps: Deps = {}): void {
218
224
  // Dedupe by mtime: one write can fire the watcher twice (rename+change), and
219
225
  // two saves with identical name/dest/action have identical CONTENT, so mtime
220
226
  // - which advances on every write - is the reliable "is this a new save" key.
227
+ // Seed from any file left by a PRIOR session at this same sid so a stray dir
228
+ // event (macOS can deliver a null filename, bypassing the name filter) can
229
+ // never re-deliver that stale note - only a fresh write, with a new mtime,
230
+ // fires.
221
231
  let lastMtimeMs = -1;
232
+ try {
233
+ lastMtimeMs = mtimeMs(target);
234
+ } catch {
235
+ // no pre-existing file - the first real save is a genuine new event
236
+ }
222
237
  const handle = startWatch(dir, (_event: string, changed: string | null) => {
223
238
  if (changed !== null && changed !== fileName) return;
224
239
  let m: number;
package/src/watch.test.ts CHANGED
@@ -23,29 +23,79 @@ function fakePi() {
23
23
  // watchDeps wires a fully-injected Deps: no real fs, no real tmux. `fire`
24
24
  // invokes the captured watch callback for this session's file; `closeFake`
25
25
  // reports whether the handle was closed.
26
- function watchDeps(overrides: Partial<Deps> = {}) {
26
+ // watchDeps models a file that is ABSENT until a save creates it: eventMtimeMs
27
+ // throws while `present` is false (statSync's real behavior on a missing file),
28
+ // so the startup mtime-seed only kicks in when the file already exists. `fire`
29
+ // marks the file present (a save wrote it) before invoking the watch callback,
30
+ // mirroring reality. Pass presentAtStartup:true to simulate a stale file left
31
+ // by a prior session.
32
+ function watchDeps(
33
+ overrides: Partial<Deps> = {},
34
+ opts: { presentAtStartup?: boolean; mtime?: number } = {},
35
+ ) {
27
36
  let cb: ((e: string, f: string | null) => void) | undefined;
28
37
  let closed = false;
38
+ let watchedDir: string | undefined;
39
+ const mkdirCalls: string[] = [];
40
+ let present = opts.presentAtStartup ?? false;
41
+ let mt = opts.mtime ?? 1000;
29
42
  const deps: Deps = {
30
43
  resolveTmux: () => ({ socket: "s", pane: "%1" }),
31
44
  resolveSessionId: () => "$3",
32
45
  homedir: () => "/home/u",
33
- mkdir: () => {},
34
- watch: (_dir, c) => {
46
+ mkdir: (d) => {
47
+ mkdirCalls.push(d);
48
+ },
49
+ watch: (dir, c) => {
50
+ watchedDir = dir;
35
51
  cb = c;
36
52
  return { close() { closed = true; } } as WatchHandle;
37
53
  },
38
54
  readEventFile: () => '{"name":"STRIPE_KEY","dest":".env","action":"added"}',
39
- eventMtimeMs: () => 1000,
55
+ eventMtimeMs: () => {
56
+ if (!present) throw new Error("ENOENT");
57
+ return mt;
58
+ },
40
59
  ...overrides,
41
60
  };
42
61
  return {
43
62
  deps,
44
- fire: (file: string | null = "$3.json") => cb?.("change", file),
63
+ fire: (file: string | null = "$3.json") => {
64
+ present = true;
65
+ cb?.("change", file);
66
+ },
67
+ setMtime: (v: number) => {
68
+ mt = v;
69
+ },
45
70
  isClosed: () => closed,
71
+ watchedDir: () => watchedDir,
72
+ mkdirCalls: () => mkdirCalls,
46
73
  };
47
74
  }
48
75
 
76
+ test("watches (and creates) a socket-scoped events dir, not the shared root", () => {
77
+ // session ids (`$0`, `$1`...) are unique only within one tmux server, so the
78
+ // routing key must include the socket name or a save on one socket wakes a
79
+ // same-id session on another. The watcher scopes to <root>/<socket>.
80
+ const { pi } = fakePi();
81
+ const { deps, watchedDir, mkdirCalls } = watchDeps();
82
+ startCreelWatch(pi, deps);
83
+ assert.equal(watchedDir(), "/home/u/.pi/agent/creel-events/s");
84
+ assert.ok(mkdirCalls().includes("/home/u/.pi/agent/creel-events/s"));
85
+ });
86
+
87
+ test("a pre-existing stale event file does not fire on the first spurious event", () => {
88
+ // If this session's file already exists at startup (a prior save at the same
89
+ // sid), a stray dir event (macOS can deliver a null filename) must not
90
+ // re-deliver the old note. Seeding lastMtimeMs from the file at startup guards
91
+ // this: an unchanged mtime is skipped.
92
+ const { pi, sent } = fakePi();
93
+ const { deps, fire } = watchDeps({}, { presentAtStartup: true, mtime: 1000 });
94
+ startCreelWatch(pi, deps);
95
+ fire(null); // null filename falls through the name filter; mtime is unchanged
96
+ assert.equal(sent.length, 0);
97
+ });
98
+
49
99
  test("delivers a value-free note on a new event, queued after the turn", () => {
50
100
  const { pi, sent } = fakePi();
51
101
  const { deps, fire } = watchDeps();
@@ -69,13 +119,12 @@ test("an idle session steers a fresh turn", () => {
69
119
 
70
120
  test("the same mtime does not re-notify; a new mtime does", () => {
71
121
  const { pi, sent } = fakePi();
72
- let mt = 1000;
73
- const { deps, fire } = watchDeps({ eventMtimeMs: () => mt });
122
+ const { deps, fire, setMtime } = watchDeps();
74
123
  startCreelWatch(pi, deps);
75
124
  fire();
76
125
  fire(); // double-fire of one write -> same mtime -> skipped
77
126
  assert.equal(sent.length, 1);
78
- mt = 2000; // a genuinely new save
127
+ setMtime(2000); // a genuinely new save
79
128
  fire();
80
129
  assert.equal(sent.length, 2);
81
130
  });