@jals1212/ci-local-pi 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -6,6 +6,26 @@ add or change behaviour and a patch version fixes.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.4.0
10
+
11
+ ### Added
12
+
13
+ - Herdr sidebar: in a Herdr pane, the status line is also the pane's `$ci`
14
+ metadata token while a run is in flight and for 5 minutes after it ends, so
15
+ a `["$ci"]` row in Herdr's Agents sidebar shows ci-local next to the
16
+ agent. Display only, refreshed with a 30-second TTL, and cleared when the
17
+ session ends. (#54)
18
+
19
+ ## 0.3.1
20
+
21
+ ### Fixed
22
+
23
+ - A `ci-local.config.sh` written after the session started is now followed:
24
+ `ci_local_run`, or the end of a turn, sets up the status line, the cards and
25
+ the end card, which before came only after a `/reload`. When nothing can
26
+ follow the run (no UI, or no events file), `ci_local_run` no longer promises
27
+ a footer or an end card. (#51)
28
+
9
29
  ## 0.3.0
10
30
 
11
31
  ### Changed
package/README.md CHANGED
@@ -35,6 +35,26 @@ runs ci-local or Docker itself:
35
35
  shows it where it lists extension statuses: Gentle Shell, for one, under
36
36
  **Integrations** in the sidebar's Status card (or in its bottom bar on a
37
37
  narrow terminal).
38
+ - **Herdr sidebar**: in a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`,
39
+ `HERDR_PANE_ID`, `HERDR_SOCKET_PATH`), the status line is also the pane's
40
+ `ci` metadata token while a run is queued or running, and its result for 5
41
+ minutes after it ends. The token is display only: the agent's state in
42
+ Herdr does not change. It is reported again every 10 seconds with a
43
+ 30-second TTL, so it goes away when Pi does. To show it, add a `$ci` row to
44
+ the Agents sidebar in Herdr's `config.toml`, then run `herdr server reload-config`:
45
+
46
+ ```toml
47
+ [ui.sidebar.agents]
48
+ rows = [
49
+ ["state_icon", "machine", "workspace", "tab"],
50
+ ["agent"],
51
+ [{ token = "$ci", rules = [
52
+ { contains = "✗", fg = "#f38ba8" },
53
+ { contains = "✓", fg = "#a6e3a1" },
54
+ { contains = "⧗", fg = "#f9e2af" },
55
+ ] }],
56
+ ]
57
+ ```
38
58
  - **Only the session's own runs** get the cards and the notification, and
39
59
  reach the agent: runs this session started. A run started from Pi's bash
40
60
  tool names its session (ci-local records Pi's `PI_SESSION_ID` as `session`
@@ -13,6 +13,9 @@
13
13
  // this session's process (a background job, even orphaned). Another
14
14
  // session's run in the same repository, or one started in a terminal, shows
15
15
  // in the status line alone.
16
+ // - Herdr: in a pane Herdr runs, the status line is also the pane's `$ci`
17
+ // metadata token while a run is in flight and for 5 minutes after it ends,
18
+ // so a `["$ci"]` row in Herdr's Agents sidebar shows it (display only).
16
19
  // - When a run starts: a card in the transcript when it is starting (or
17
20
  // waiting for its turn), another when its steps start; for you only (session
18
21
  // entries, never in the model's context, never repeated after a reload).
@@ -42,6 +45,8 @@ import {
42
45
  LAUNCH_WAIT_MS,
43
46
  WAKE_TEXT,
44
47
  cardContent,
48
+ createHerdrLine,
49
+ createHerdrPublisher,
45
50
  createDelivery,
46
51
  createStatusLine,
47
52
  createTracker,
@@ -49,6 +54,8 @@ import {
49
54
  eventsFileFor,
50
55
  exitCard,
51
56
  findProject,
57
+ herdrPane,
58
+ herdrSend,
52
59
  launchReport,
53
60
  launchRun,
54
61
  ownsRun,
@@ -89,12 +96,19 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
89
96
  // agent_start to agent_settled (in-run compaction and retries included).
90
97
  let delivery: ReturnType<typeof createDelivery> | undefined
91
98
  let runActive = false
99
+ // The reason of the session's session_start (createStatusLine reads it);
100
+ // undefined before one and after session_shutdown, when nothing is followed.
101
+ let startReason: string | undefined
92
102
  // The runs ci_local_run started, by pid, until their run shows in the events
93
103
  // file (seen) or they end. One that ends unseen, after the tool returned,
94
104
  // gets an end card of its own (exitCard): no event will ever name it. Each
95
105
  // belongs to the session that started it; another session drops it.
96
106
  type Launch = ReturnType<typeof launchRun> & { seen: boolean; waiting: boolean; sessionId: string }
97
107
  const launches = new Map<number, Launch>()
108
+ // In a Herdr pane, the footer line is also the pane's `$ci` sidebar token
109
+ // (see createHerdrLine in core.js); null elsewhere. Read when a session
110
+ // starts following, so a test or a moved pane gets its own.
111
+ let herdr: { pane: string; socket: string; publisher: ReturnType<typeof createHerdrPublisher> } | undefined
98
112
 
99
113
  pi.on('agent_start', async () => {
100
114
  runActive = true
@@ -104,7 +118,10 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
104
118
  runActive = false
105
119
  delivery?.boundary()
106
120
  })
107
- pi.on('turn_end', async () => delivery?.boundary())
121
+ pi.on('turn_end', async (_event, ctx) => {
122
+ follow(ctx)
123
+ delivery?.boundary()
124
+ })
108
125
  pi.on('session_compact', async () => delivery?.boundary())
109
126
  pi.on('session_compact_failed', async () => delivery?.boundary())
110
127
 
@@ -167,6 +184,8 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
167
184
  async execute(_id, params, signal, _onUpdate, ctx) {
168
185
  const found = findProject(ctx.cwd)
169
186
  if (!found) throw new Error(`no ci-local.config.sh with a literal CI_PROJECT at or above ${ctx.cwd}`)
187
+ // Followed before the launch, so the tracker is there when the run starts.
188
+ const followed = follow(ctx)
170
189
  const sessionId = ctx.sessionManager.getSessionId()
171
190
  const launch = launchRun({ repo: found.repo, args: params.args ?? [], sessionId })
172
191
  const tracked: Launch = Object.assign(launch, { seen: false, waiting: true, sessionId })
@@ -178,7 +197,7 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
178
197
  if (launch.pid !== undefined) launches.delete(launch.pid)
179
198
  if (launch.error) throw new Error(`cannot start ${launch.bin}: ${launch.error}`)
180
199
  }
181
- return { content: [{ type: 'text', text: launchReport(result, launch, found.project) }], details: { kind: result.kind, pid: launch.pid } }
200
+ return { content: [{ type: 'text', text: launchReport(result, launch, found.project, { followed }) }], details: { kind: result.kind, pid: launch.pid } }
182
201
  },
183
202
  })
184
203
 
@@ -197,11 +216,25 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
197
216
  pi.on('session_start', async (event, ctx: ExtensionContext) => {
198
217
  stop()
199
218
  runActive = false
200
- if (!ctx.hasUI) return
219
+ startReason = event.reason
220
+ follow(ctx)
221
+ })
222
+
223
+ /**
224
+ * Sets up what follows the runs of the session's repository (the tick timer,
225
+ * the status line, the cards, the delivery to the agent); true once it is
226
+ * running. session_start calls it, and so do ci_local_run and turn_end while
227
+ * nothing is followed yet: a ci-local.config.sh written during the session
228
+ * is picked up then, not only after a /reload (#51).
229
+ */
230
+ function follow(ctx: ExtensionContext): boolean {
231
+ if (timer) return true
232
+ if (startReason === undefined || !ctx.hasUI) return false
233
+ const reason = startReason
201
234
  const found = findProject(ctx.cwd)
202
- if (!found) return
235
+ if (!found) return false
203
236
  const file = eventsFileFor(found.project, process.env, found.repo)
204
- if (!file) return // CI_LOCAL_EVENTS=off, or no private cache: runs write no events
237
+ if (!file) return false // CI_LOCAL_EVENTS=off, or no private cache: runs write no events
205
238
  const tracker = createTracker()
206
239
  // Start cards this session already shows (a reload, a resumed session): not again.
207
240
  for (const entry of ctx.sessionManager.getBranch()) {
@@ -226,7 +259,12 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
226
259
  // The status line leaves out a run already over whose result a session
227
260
  // already showed, and records each finished run it shows (see
228
261
  // createStatusLine in core.js).
229
- const statusLine = createStatusLine({ file, sessionId, reason: event.reason })
262
+ const statusLine = createStatusLine({ file, sessionId, reason })
263
+ const pane = herdrPane(process.env)
264
+ if (pane && (herdr?.pane !== pane.pane || herdr.socket !== pane.socket)) {
265
+ herdr = { ...pane, publisher: createHerdrPublisher({ send: herdrSend(pane) }) }
266
+ }
267
+ const herdrLine = pane ? createHerdrLine() : undefined
230
268
 
231
269
  const tick = () => {
232
270
  let run
@@ -237,6 +275,7 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
237
275
  }
238
276
  const { footer, changed } = statusLine.update(run)
239
277
  if (changed) ctx.ui.setStatus(KEY, footer)
278
+ if (herdrLine) herdr?.publisher.update(herdrLine.text(run))
240
279
  settleLaunches(run)
241
280
  // Only this session's own runs get cards and reach the agent, as Gentle
242
281
  // AI's job notices go only to the session that started the job. Another
@@ -309,10 +348,17 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
309
348
  tick()
310
349
  timer = setInterval(tick, POLL_MS)
311
350
  timer.unref?.()
312
- })
351
+ return true
352
+ }
313
353
 
314
354
  pi.on('session_shutdown', async () => {
315
355
  stop()
356
+ // The sidebar token goes with the session (its TTL would end it anyway).
357
+ if (herdr) {
358
+ herdr.publisher.update(null)
359
+ await herdr.publisher.idle()
360
+ }
361
+ startReason = undefined
316
362
  delete (globalThis as Record<symbol, unknown>)[FLAG]
317
363
  })
318
364
  }
@@ -19,6 +19,7 @@ import {
19
19
  statSync,
20
20
  writeFileSync,
21
21
  } from 'node:fs'
22
+ import { createConnection } from 'node:net'
22
23
  import { tmpdir } from 'node:os'
23
24
  import { dirname, isAbsolute, join } from 'node:path'
24
25
 
@@ -618,14 +619,21 @@ function endedLine(launch) {
618
619
  return `ci-local ${verb} ${howEnded(launch)} before a run started`
619
620
  }
620
621
 
621
- /** What ci_local_run tells the agent of a launch and how it stands. */
622
- export function launchReport(result, launch, project) {
622
+ /**
623
+ * What ci_local_run tells the agent of a launch and how it stands. followed:
624
+ * whether this session follows the run (a footer and an end card will come).
625
+ */
626
+ export function launchReport(result, launch, project, { followed = true } = {}) {
623
627
  const where = `Output: ${launch.output}.`
624
- const follow = 'The footer follows it and its end card reaches you when it ends: do not poll, sleep or run `ci-local --status`.'
628
+ const follow = followed
629
+ ? 'The footer follows it and its end card reaches you when it ends: do not poll, sleep or run `ci-local --status`.'
630
+ : 'This session cannot follow it (no UI, or no events file to read), so no footer or end card will come: read its output, or ask ci_local_status.'
625
631
  if (result.kind === 'started')
626
632
  return `ci-local started (pid ${launch.pid}) for ${project}: ${stateText(result.run.state)}. ${follow} ${where}`
627
- if (result.kind === 'waiting')
628
- return `ci-local launched (pid ${launch.pid}) for ${project}, no event yet: another run of ${project} may hold its events file until it ends. ${follow} ${where}`
633
+ if (result.kind === 'waiting') {
634
+ const why = followed ? `: another run of ${project} may hold its events file until it ends` : ''
635
+ return `ci-local launched (pid ${launch.pid}) for ${project}, no event yet${why}. ${follow} ${where}`
636
+ }
629
637
  const tail = outputTail(launch.output)
630
638
  return [
631
639
  `${endedLine(launch)}. ${where}`,
@@ -656,3 +664,153 @@ export function exitCard({ pid, code, signal = null, output, tail = outputTail(o
656
664
  },
657
665
  }
658
666
  }
667
+
668
+ // Herdr (https://herdr.dev): in a pane Herdr runs, the footer line is also the
669
+ // pane's `$ci` metadata token, so a row `["$ci"]` in Herdr's Agents sidebar
670
+ // shows the run. A token is display only: it never touches the agent's state.
671
+
672
+ /** The token's name, as a sidebar row names it: `$ci`. */
673
+ export const HERDR_TOKEN = 'ci'
674
+ /** Who reports the token. No sequence numbers: one sender, one report at a time. */
675
+ export const HERDR_SOURCE = 'ci-local'
676
+ /** Herdr drops the token this long after the last report: a Pi gone leaves nothing behind. */
677
+ export const HERDR_TTL_MS = 30_000
678
+ /** A token unchanged is reported again this often, well inside its TTL. */
679
+ export const HERDR_REFRESH_MS = 10_000
680
+ /** How long a run's result stays in the sidebar after the run ends. */
681
+ export const HERDR_RESULT_MS = 5 * 60_000
682
+ /** Herdr keeps at most this many characters of a token. */
683
+ export const HERDR_TOKEN_MAX = 80
684
+
685
+ /** The pane and socket of the Herdr pane this process runs in, or null outside one. */
686
+ export function herdrPane(env = process.env) {
687
+ const pane = env.HERDR_PANE_ID?.trim()
688
+ const socket = env.HERDR_SOCKET_PATH?.trim()
689
+ return env.HERDR_ENV === '1' && pane && socket ? { pane, socket } : null
690
+ }
691
+
692
+ function clip(text) {
693
+ const chars = [...text]
694
+ return chars.length <= HERDR_TOKEN_MAX ? text : chars.slice(0, HERDR_TOKEN_MAX - 1).join('') + '…'
695
+ }
696
+
697
+ /**
698
+ * What the token says for a run: the footer line while it is in flight, and
699
+ * its result for HERDR_RESULT_MS after it ended (by its end event; a run that
700
+ * stopped without one, from when this session saw it stop). Null otherwise:
701
+ * no run, or a result older than that, or a run found already stopped.
702
+ */
703
+ export function createHerdrLine({ now = Date.now } = {}) {
704
+ let flying // the key of the last run seen in flight
705
+ const endedAt = new Map()
706
+ return {
707
+ text(run) {
708
+ if (!run) return null
709
+ if (!FINISHED.has(run.state.state)) {
710
+ flying = run.key
711
+ return clip(footerText(run.state))
712
+ }
713
+ if (!endedAt.has(run.key)) {
714
+ const ended = Date.parse(run.state.end?.ts ?? '')
715
+ endedAt.clear()
716
+ endedAt.set(run.key, Number.isFinite(ended) ? ended : run.key === flying ? now() : null)
717
+ }
718
+ const ended = endedAt.get(run.key)
719
+ return ended !== null && now() - ended < HERDR_RESULT_MS ? clip(footerText(run.state)) : null
720
+ },
721
+ }
722
+ }
723
+
724
+ /** The socket request that sets the token (with its TTL), or clears it (null). */
725
+ export function herdrReport(pane, value) {
726
+ const params = { pane_id: pane, source: HERDR_SOURCE, tokens: { [HERDR_TOKEN]: value } }
727
+ if (value !== null) params.ttl_ms = HERDR_TTL_MS
728
+ return { method: 'pane.report_metadata', params }
729
+ }
730
+
731
+ let herdrRequests = 0
732
+
733
+ /**
734
+ * A sender of the token to Herdr's socket (one JSON line, one reply): resolves
735
+ * true when Herdr accepted it, false on any error or after `timeoutMs`.
736
+ */
737
+ export function herdrSend({ pane, socket }, { timeoutMs = 2000 } = {}) {
738
+ return (value) =>
739
+ new Promise((resolve) => {
740
+ const id = `ci-local-${process.pid}-${++herdrRequests}`
741
+ let reply = ''
742
+ let done = false
743
+ const finish = (ok) => {
744
+ if (done) return
745
+ done = true
746
+ clearTimeout(timer)
747
+ conn.destroy()
748
+ resolve(ok)
749
+ }
750
+ const conn = createConnection(socket)
751
+ const timer = setTimeout(() => finish(false), timeoutMs)
752
+ timer.unref?.()
753
+ conn.on('connect', () => conn.write(JSON.stringify({ id, ...herdrReport(pane, value) }) + '\n'))
754
+ conn.on('data', (data) => {
755
+ reply += data
756
+ const end = reply.indexOf('\n')
757
+ if (end < 0) return
758
+ try {
759
+ const answer = JSON.parse(reply.slice(0, end))
760
+ finish(answer.id === id && answer.result?.type === 'ok')
761
+ } catch {
762
+ finish(false)
763
+ }
764
+ })
765
+ conn.on('error', () => finish(false))
766
+ conn.on('close', () => finish(false))
767
+ })
768
+ }
769
+
770
+ /**
771
+ * Keeps Herdr's token in step with `update(value)` (null clears it): a report
772
+ * when the value changes, again every HERDR_REFRESH_MS while it stays, one
773
+ * report at a time (the latest value waits for the one on its way). A failed
774
+ * report is tried again at the next update. `idle()` resolves once nothing is
775
+ * on its way.
776
+ */
777
+ export function createHerdrPublisher({ send, now = Date.now, refreshMs = HERDR_REFRESH_MS }) {
778
+ let shown = null // what Herdr has
779
+ let sentAt = 0
780
+ let inFlight = null
781
+ let pending // the value to report once inFlight is done
782
+ const update = (value) => {
783
+ if (inFlight) {
784
+ pending = value
785
+ return
786
+ }
787
+ const due = value !== shown || (value !== null && now() - sentAt >= refreshMs)
788
+ if (!due) return
789
+ let sending
790
+ try {
791
+ sending = Promise.resolve(send(value))
792
+ } catch {
793
+ sending = Promise.resolve(false)
794
+ }
795
+ inFlight = sending
796
+ .catch(() => false)
797
+ .then((ok) => {
798
+ if (ok) {
799
+ shown = value
800
+ sentAt = now()
801
+ }
802
+ inFlight = null
803
+ if (pending !== undefined) {
804
+ const next = pending
805
+ pending = undefined
806
+ update(next)
807
+ }
808
+ })
809
+ }
810
+ return {
811
+ update,
812
+ async idle() {
813
+ while (inFlight) await inFlight
814
+ },
815
+ }
816
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jals1212/ci-local-pi",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Pi extension and skill for @jals1212/ci-local: the state of this repository's ci-local run in Pi's status line, cards and a read-only tool for the agent.",
5
5
  "license": "MIT",
6
6
  "author": "jals1212",
@@ -4,7 +4,7 @@ description: "Trigger: ci-local, run CI locally, ci-local.config.sh, CI_PROJECT,
4
4
  license: MIT
5
5
  metadata:
6
6
  author: jals1212
7
- version: "0.3.0"
7
+ version: "0.4.0"
8
8
  ---
9
9
 
10
10
  ## Activation Contract