@jals1212/ci-local-pi 0.3.1 → 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,16 @@ 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
+
9
19
  ## 0.3.1
10
20
 
11
21
  ### Fixed
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,
@@ -98,6 +105,10 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
98
105
  // belongs to the session that started it; another session drops it.
99
106
  type Launch = ReturnType<typeof launchRun> & { seen: boolean; waiting: boolean; sessionId: string }
100
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
101
112
 
102
113
  pi.on('agent_start', async () => {
103
114
  runActive = true
@@ -249,6 +260,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
249
260
  // already showed, and records each finished run it shows (see
250
261
  // createStatusLine in core.js).
251
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
252
268
 
253
269
  const tick = () => {
254
270
  let run
@@ -259,6 +275,7 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
259
275
  }
260
276
  const { footer, changed } = statusLine.update(run)
261
277
  if (changed) ctx.ui.setStatus(KEY, footer)
278
+ if (herdrLine) herdr?.publisher.update(herdrLine.text(run))
262
279
  settleLaunches(run)
263
280
  // Only this session's own runs get cards and reach the agent, as Gentle
264
281
  // AI's job notices go only to the session that started the job. Another
@@ -336,6 +353,11 @@ function ciLocal(pi: ExtensionAPI, { launchWaitMs }: { launchWaitMs: number }) {
336
353
 
337
354
  pi.on('session_shutdown', async () => {
338
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
+ }
339
361
  startReason = undefined
340
362
  delete (globalThis as Record<symbol, unknown>)[FLAG]
341
363
  })
@@ -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
 
@@ -663,3 +664,153 @@ export function exitCard({ pid, code, signal = null, output, tail = outputTail(o
663
664
  },
664
665
  }
665
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.1",
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.1"
7
+ version: "0.4.0"
8
8
  ---
9
9
 
10
10
  ## Activation Contract