jackherd 1.15.1 → 1.16.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +22 -19
  3. package/dist/{chunk-V4HOLGFP.js → chunk-4LUFR7RC.js} +19 -1
  4. package/dist/config.d.ts +47 -2
  5. package/dist/config.js +65 -4
  6. package/dist/plan-format.js +3 -19
  7. package/herdr-plugin/herdr-plugin.toml +1 -1
  8. package/install.sh +49 -4
  9. package/kit/hooks/hooks/merge-guard/decision.ts +17 -2
  10. package/kit/workflow/skills/follow/SKILL.md +18 -10
  11. package/kit/workflow/skills/orchestrate/SKILL.md +9 -6
  12. package/package.json +2 -1
  13. package/recipes/README.md +3 -2
  14. package/recipes/google-calendar.md +30 -0
  15. package/recipes/sources/google-calendar-today.ts +32 -0
  16. package/recipes/sources/google-calendar.ts +368 -0
  17. package/recipes/sources.md +15 -3
  18. package/schemas/jackherd.schema.json +12 -0
  19. package/schemas/sources.schema.json +141 -0
  20. package/src/check/skill-budget-defaults.ts +11 -4
  21. package/src/cli.ts +1 -0
  22. package/src/config/json-schema.ts +7 -3
  23. package/src/config/location.ts +7 -0
  24. package/src/config/personal-sources.ts +83 -0
  25. package/src/config/schema.ts +29 -0
  26. package/src/plans/herd-claim.ts +9 -31
  27. package/src/plans/layout.ts +135 -18
  28. package/src/plans/live.ts +54 -7
  29. package/src/plans/row-actions.ts +17 -10
  30. package/src/plans/settings.ts +20 -7
  31. package/src/plans/snapshot.ts +34 -22
  32. package/src/plans/source-rows.ts +35 -2
  33. package/src/secrets/command.ts +163 -0
  34. package/src/secrets/environment.ts +45 -0
  35. package/src/secrets/hidden-input.ts +51 -0
  36. package/src/secrets/listing.ts +63 -0
  37. package/src/secrets/options.ts +45 -0
  38. package/src/secrets/store.ts +74 -0
  39. package/src/shared/process-info.ts +89 -0
  40. package/src/sync/command-context.ts +26 -4
  41. package/src/sync/render.ts +9 -4
  42. package/src/watch/command.ts +44 -12
  43. package/src/watch/lane-report.ts +2 -1
  44. package/src/watch/plan-badges-read.ts +28 -10
  45. package/src/watch/source-dry-run-command.ts +23 -12
  46. package/src/watch/source-dry-run.ts +20 -3
  47. package/src/watch/source-reload.ts +100 -0
  48. package/src/watch/source-run.ts +63 -7
  49. package/src/watch/source-tickets.ts +15 -0
  50. package/src/watch/sources.ts +21 -0
  51. package/src/watch/texts.ts +1 -1
  52. package/src/watch/watch-events.ts +9 -0
  53. package/src/watch/watched-source.ts +39 -0
  54. package/src/watch/watcher-lock.ts +8 -1
@@ -2,7 +2,7 @@
2
2
 
3
3
  The fields and limits of a `watch.sources` entry, for a source no [recipe](README.md) covers or a field a recipe leaves open.
4
4
 
5
- Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout, with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
5
+ Source reports are off unless the config lists `watch.sources`. The watcher cannot use the MCP servers of a Claude Code session: their connections and logins (often OAuth in a browser) belong to the session. A source is a command instead, which prints JSON on stdout. It runs without a shell, in the main checkout (a personal source: below), with your environment, so its token stays where it is. `/jh-follow` fills one of the [recipes](README.md) for you.
6
6
 
7
7
  ```json
8
8
  {
@@ -76,9 +76,21 @@ dry run source tickets: ["tickets-cli","search","--query","key in (T-123)","--js
76
76
  first run of this source: baseline, nothing reported; later a new or changed entry reports like this:
77
77
  === source tickets (search) T-123 Decide the layout · new
78
78
  Yes, take variant B.
79
- watcher state not read or changed; the watcher takes a new source over at its next start
79
+ watcher state not read or changed; a running watcher takes a new source over at its next cycle
80
80
  ```
81
81
 
82
- The watcher reads `watch.sources` when it starts: a new or changed source counts from its next start.
82
+ **Keep a personal source.** A source can live in `~/.jackherd/sources.json` instead of the repository: `{ "$schema": "…/schemas/sources.schema.json", "sources": [ … ] }`, entries as above. It runs in every repository where the watcher starts, after the repository's `watch.sources`. On a name clash the repository's source wins and the personal one is skipped with a note. A personal source runs with `~/.jackherd/` as its working directory, so its script is `~/.jackherd/sources/<name>.ts` and its command `["bun", "sources/<name>.ts"]`. `jh watch source <name> --dry-run` finds it too and prints `origin: personal, <path>`; a draft file runs as a repository source, in the main checkout and without the keychain.
83
+
84
+ **Keep a secret for a source.** `"secrets": ["NAME"]` (up to 10 names, `A-Z`, digits and `_`, starting with a letter) puts those variables into the environment of the source's command, and nowhere else. For a personal source each value comes from the keychain (service `jackherd`), else from the watcher's own environment; a repository source gets them from the watcher's environment only, never from the keychain. A value that is found nowhere fails the run with `secret missing: NAME`, an unreachable keychain with `secret store unavailable`; other sources go on. The script reads the value from `process.env` and never prints it.
85
+
86
+ ```text
87
+ jh secret set NAME asks for the value hidden, needs a terminal (exit 2 without), never takes it as argument or from stdin
88
+ jh secret list each name the sources need: set, set (environment), missing, missing (environment only), secret store unavailable
89
+ jh secret delete NAME
90
+ ```
91
+
92
+ **Show entries as content.** `"show": "entries"` opens the source's entries in a herd block of their own under "Your turn", headed by its `label`, instead of the folded row in `Sources`. For a source whose entries are the day's content, such as a calendar; not together with `ticket`.
93
+
94
+ The watcher and the herd read the repository's config and the personal file again each cycle (about a minute): a new, changed or removed source counts without a restart, a new one runs at once. A changed file that is invalid keeps the previous sources and prints `sources not reloaded: <file>: <reason>`. The rest of the config is read when the watcher starts.
83
95
 
84
96
  Three limits stand: the watcher cannot make a command read only, so name only commands that read. A source in `.jackherd/config.json` runs for everyone who starts the watcher in the repository, at the same level of trust as `check.checks` and hooks. The watcher reads the config of the main checkout, never the one of a lane's worktree; a dry run started inside a worktree reads that worktree's config first. And `jh watch source … --dry-run` runs whatever command its draft names, so it never belongs in a permission allowlist.
@@ -633,6 +633,18 @@
633
633
  "type": "string",
634
634
  "minLength": 1
635
635
  }
636
+ },
637
+ "secrets": {
638
+ "maxItems": 10,
639
+ "type": "array",
640
+ "items": {
641
+ "type": "string",
642
+ "pattern": "^[A-Z][A-Z0-9_]*$"
643
+ }
644
+ },
645
+ "show": {
646
+ "type": "string",
647
+ "const": "entries"
636
648
  }
637
649
  },
638
650
  "required": [
@@ -0,0 +1,141 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "$schema": {
6
+ "type": "string"
7
+ },
8
+ "sources": {
9
+ "default": [],
10
+ "type": "array",
11
+ "items": {
12
+ "type": "object",
13
+ "properties": {
14
+ "name": {
15
+ "type": "string",
16
+ "pattern": "^[A-Za-z0-9][\\w.-]*$"
17
+ },
18
+ "command": {
19
+ "minItems": 1,
20
+ "type": "array",
21
+ "items": {
22
+ "type": "string",
23
+ "minLength": 1
24
+ }
25
+ },
26
+ "every": {
27
+ "default": 5,
28
+ "type": "integer",
29
+ "minimum": 1,
30
+ "maximum": 9007199254740991
31
+ },
32
+ "items": {
33
+ "type": "string"
34
+ },
35
+ "map": {
36
+ "type": "object",
37
+ "properties": {
38
+ "id": {
39
+ "type": "string"
40
+ },
41
+ "updatedAt": {
42
+ "type": "string"
43
+ },
44
+ "title": {
45
+ "type": "string"
46
+ },
47
+ "url": {
48
+ "type": "string"
49
+ },
50
+ "body": {
51
+ "type": "string"
52
+ },
53
+ "author": {
54
+ "type": "string"
55
+ },
56
+ "status": {
57
+ "type": "string"
58
+ },
59
+ "count": {
60
+ "type": "string"
61
+ }
62
+ },
63
+ "required": [
64
+ "id"
65
+ ],
66
+ "additionalProperties": {
67
+ "type": "string"
68
+ }
69
+ },
70
+ "ignoreAuthors": {
71
+ "default": [],
72
+ "type": "array",
73
+ "items": {
74
+ "type": "string",
75
+ "minLength": 1
76
+ }
77
+ },
78
+ "reportLimit": {
79
+ "default": 5,
80
+ "type": "integer",
81
+ "minimum": 1,
82
+ "maximum": 9007199254740991
83
+ },
84
+ "label": {
85
+ "type": "string",
86
+ "minLength": 1,
87
+ "maxLength": 40,
88
+ "pattern": "^[^\\r\\n]+$"
89
+ },
90
+ "lane": {
91
+ "type": "string",
92
+ "minLength": 1
93
+ },
94
+ "ticket": {
95
+ "type": "boolean"
96
+ },
97
+ "tone": {
98
+ "type": "string",
99
+ "enum": [
100
+ "amber",
101
+ "teal",
102
+ "grey",
103
+ "red",
104
+ "green",
105
+ "blue",
106
+ "magenta"
107
+ ]
108
+ },
109
+ "reportOn": {
110
+ "minItems": 1,
111
+ "maxItems": 10,
112
+ "type": "array",
113
+ "items": {
114
+ "type": "string",
115
+ "minLength": 1
116
+ }
117
+ },
118
+ "secrets": {
119
+ "maxItems": 10,
120
+ "type": "array",
121
+ "items": {
122
+ "type": "string",
123
+ "pattern": "^[A-Z][A-Z0-9_]*$"
124
+ }
125
+ },
126
+ "show": {
127
+ "type": "string",
128
+ "const": "entries"
129
+ }
130
+ },
131
+ "required": [
132
+ "name",
133
+ "command",
134
+ "map"
135
+ ],
136
+ "additionalProperties": false
137
+ }
138
+ }
139
+ },
140
+ "additionalProperties": false
141
+ }
@@ -16,7 +16,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
16
16
  '.claude/skills/devops/SKILL.md': 321,
17
17
  '.claude/skills/diagnose/SKILL.md': 1521,
18
18
  '.claude/skills/diagnose/references/feedback-loops.md': 636,
19
- '.claude/skills/follow/SKILL.md': 1100,
19
+ // Personal sources: where the source lives, the secret step that keeps the value out of the agent, the personal
20
+ // file and its script path, what a reload prints (+295); skill-review: a personal source stores its secret and is
21
+ // dry-run by name after the entry is written, since a draft runs without the keychain (+103).
22
+ '.claude/skills/follow/SKILL.md': 1498,
20
23
  '.claude/skills/grill/SKILL.md': 685,
21
24
  '.claude/skills/implement/SKILL.md': 2190,
22
25
  // P33: the paw in the header chip, panes by tab and title, the lane in the context line of a passed-on question.
@@ -27,7 +30,10 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
27
30
  // Cross-check: measured in a repository whose stack nothing detects, every unset command renders as a note (+7).
28
31
  // Ready phase: the `done` / `idle` row names the watcher's mandatory `ready:` line (+20).
29
32
  // Drafts until the merge: § 5 step 5 opens a draft and readies it, Self-merge step 3 waits for the runs after it (+100).
30
- '.claude/skills/orchestrate/SKILL.md': 8230,
33
+ // Ready round: a PR of the lane waiting for approval or merge holds only the commit; the self-merge command runs
34
+ // alone, so the guard can check it (+132, the longest rendering of the kit's renderings); skill-review: the commit
35
+ // of a PR awaiting approval stages only its files, a branch over a squash-merged PR moves with `--onto` (+51).
36
+ '.claude/skills/orchestrate/SKILL.md': 8413,
31
37
  // skill-review: the copy is taken after a mirror, and rejecting together with a remark is a remark (+36).
32
38
  // Cross-check: an adopted plan also gets short names and a no-review-gate line, which `jh check` asks of a new
33
39
  // plan; the proof became a word diff, since a short name goes into an existing line (+74); skill-review: the
@@ -55,8 +61,9 @@ export const KIT_SKILL_BUDGETS: Readonly<Record<string, number>> = {
55
61
  // Any language: step 3 points to proposing the missing commands from the CI; the proposal itself is a reference.
56
62
  '.claude/skills/start/SKILL.md': 630,
57
63
  '.claude/skills/uncle-bob/SKILL.md': 466,
58
- // P33 and open up: grows with the skill above.
59
- '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 8880,
64
+ // P33 and open up: grows with the skill above (+132 for the ready round and the lone merge call); measured with
65
+ // this repository's supplement after the skill-review fixes (+153).
66
+ '.claude/skills/orchestrate/SKILL.md + .claude/project/orchestrate.md': 9165,
60
67
  '.claude/agents/devops.md': 104,
61
68
  '.claude/agents/explore.md': 132,
62
69
  '.claude/agents/implement.md': 303,
package/src/cli.ts CHANGED
@@ -28,6 +28,7 @@ export const COMMANDS: CommandTable = {
28
28
  mermaid: () => import('./mermaid/command'),
29
29
  plan: () => import('./plan-contract/command'),
30
30
  preview: () => import('./preview/command'),
31
+ secret: () => import('./secrets/command'),
31
32
  settled: () => import('./settled/command'),
32
33
  sync: () => import('./sync/command'),
33
34
  task: () => import('./task-signals/command'),
@@ -1,5 +1,9 @@
1
1
  import { z } from 'zod'
2
- import { jackherdConfigSchema } from './schema'
2
+ import { jackherdConfigSchema, personalSourcesSchema } from './schema'
3
3
 
4
- export const renderConfigJsonSchema = (): string =>
5
- `${JSON.stringify(z.toJSONSchema(jackherdConfigSchema, { io: 'input' }), null, 2)}\n`
4
+ const renderJsonSchema = (schema: z.ZodType): string =>
5
+ `${JSON.stringify(z.toJSONSchema(schema, { io: 'input' }), null, 2)}\n`
6
+
7
+ export const renderConfigJsonSchema = (): string => renderJsonSchema(jackherdConfigSchema)
8
+
9
+ export const renderSourcesJsonSchema = (): string => renderJsonSchema(personalSourcesSchema)
@@ -50,3 +50,10 @@ export const findProjectRoot = (start: string, home: string, exists: (path: stri
50
50
  */
51
51
  export const locateConfigFile = (roots: string[], home: string, exists: (path: string) => boolean): string | null =>
52
52
  [...roots, home].flatMap(configCandidates).find(exists) ?? null
53
+
54
+ export const PERSONAL_SOURCES_FILE = 'sources.json'
55
+
56
+ /** The directory a personal source runs in, and holds the personal sources file. */
57
+ export const personalSourcesDirectory = (home: string): string => join(home, TOOL_DIRECTORY)
58
+
59
+ export const personalSourcesPath = (home: string): string => join(personalSourcesDirectory(home), PERSONAL_SOURCES_FILE)
@@ -0,0 +1,83 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { homedir } from 'node:os'
3
+ import { errorMessage } from '../sync/error-message'
4
+ import { SOURCE_ORIGIN, type SourceOrigin, type SourceSettings } from '../watch/sources'
5
+ import { personalSourcesPath } from './location'
6
+ import { readResolvedConfig } from './read-config'
7
+ import { parsePersonalSourcesFile } from './schema'
8
+
9
+ export type MergedSources = { sources: SourceSettings[]; notes: string[] }
10
+
11
+ /** What a note starts with when an invalid personal file was left out. */
12
+ export const PERSONAL_IGNORED = 'personal sources ignored:'
13
+
14
+ const skippedPrefix = (name: string): string => `personal source ${name} in `
15
+
16
+ const skippedNote = (name: string, path: string): string =>
17
+ `${skippedPrefix(name)}${path} skipped: the repository has a source of that name`
18
+
19
+ /** Whether a merge note concerns the source `name`: the personal source of that name was skipped, or the file was ignored. */
20
+ export const isNoteAbout = (note: string, name: string): boolean =>
21
+ note.startsWith(skippedPrefix(name)) || note.startsWith(PERSONAL_IGNORED)
22
+
23
+ const withOrigin = (sources: readonly SourceSettings[], origin: SourceOrigin): SourceSettings[] =>
24
+ sources.map((source) => ({ ...source, origin }))
25
+
26
+ /**
27
+ * The repository's sources, then the personal ones, each marked with its origin. A personal source whose name a
28
+ * repository source has is left out and named in a note; `path` is how the notes spell the personal file.
29
+ */
30
+ export const mergedSources = (
31
+ repo: readonly SourceSettings[],
32
+ personal: readonly SourceSettings[],
33
+ path: string,
34
+ ): MergedSources => {
35
+ const repoNames = new Set(repo.map(({ name }) => name))
36
+ const kept = personal.filter(({ name }) => !repoNames.has(name))
37
+ const skipped = personal.filter(({ name }) => repoNames.has(name))
38
+ return {
39
+ sources: [...withOrigin(repo, SOURCE_ORIGIN.repo), ...withOrigin(kept, SOURCE_ORIGIN.personal)],
40
+ notes: skipped.map(({ name }) => skippedNote(name, path)),
41
+ }
42
+ }
43
+
44
+ /** The personal sources of `~/.jackherd/sources.json`, none when the file is missing; an invalid file throws with its path and the issues. */
45
+ export const readPersonalSources = (
46
+ home: string = homedir(),
47
+ exists: (path: string) => boolean = existsSync,
48
+ readText: (path: string) => string = (path) => readFileSync(path, 'utf8'),
49
+ ): SourceSettings[] => {
50
+ const path = personalSourcesPath(home)
51
+ if (!exists(path)) return []
52
+ let raw: unknown
53
+ try {
54
+ raw = JSON.parse(readText(path))
55
+ } catch (error) {
56
+ throw new Error(`${path} is invalid: ${errorMessage(error)}`)
57
+ }
58
+ return parsePersonalSourcesFile(raw, path).sources
59
+ }
60
+
61
+ /**
62
+ * The repository's sources with the personal ones of `home` merged in (`mergedSources`). An invalid personal file
63
+ * never stops the caller: its error becomes a note and the repository's sources stand alone.
64
+ */
65
+ export const sourcesWithPersonal = (
66
+ repo: readonly SourceSettings[],
67
+ home: string = homedir(),
68
+ exists: (path: string) => boolean = existsSync,
69
+ readText?: (path: string) => string,
70
+ ): MergedSources => {
71
+ try {
72
+ return mergedSources(repo, readPersonalSources(home, exists, readText), personalSourcesPath(home))
73
+ } catch (error) {
74
+ return { sources: withOrigin(repo, SOURCE_ORIGIN.repo), notes: [`${PERSONAL_IGNORED} ${errorMessage(error)}`] }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The sources of the project config of the first of `roots` that has one (`readResolvedConfig`), with the personal
80
+ * ones of `home` merged in; throws like `readResolvedConfig` when the project config is missing or invalid.
81
+ */
82
+ export const configuredSources = (roots: string[], home: string = homedir()): MergedSources =>
83
+ sourcesWithPersonal(readResolvedConfig(roots, home).config.watch.sources, home)
@@ -1,9 +1,11 @@
1
1
  import { z } from 'zod'
2
2
  import { checkConfigSchema } from '../check/schema'
3
3
  import { claudePath, WORKTREES_DIRECTORY } from '../claude/paths'
4
+ import { SECRET_NAME } from '../secrets/options'
4
5
  import { PLAN_LANGUAGE, RESPONSE_ADDRESS } from '../vocabulary'
5
6
  import { CONTENT_FIELDS, SOURCE_FIELD } from '../watch/source-fields'
6
7
  import { isSourcePath } from '../watch/source-path'
8
+ import { SOURCE_SHOW } from '../watch/sources'
7
9
  import { BUN_FAILED_TEST_PATTERN, CLEAN_REVIEW_TEXT } from '../watch/watch-defaults'
8
10
  import { MAX_AUTO_WINDOW_DAYS, windowDays } from './auto-window'
9
11
  import { DEFAULT_BASE_BRANCH } from './base-branch'
@@ -289,6 +291,17 @@ const sourceSchema = z
289
291
  * `author`, `body` and `url`; without it a change of that content reports.
290
292
  */
291
293
  reportOn: z.array(z.string().min(1)).min(1).max(10).optional(),
294
+ /**
295
+ * Names of secrets the command needs, handed to it as environment variables of the same name; each is looked up
296
+ * in the keychain, then in the watcher's environment.
297
+ */
298
+ secrets: z
299
+ .array(z.string().regex(SECRET_NAME, 'capital letters, digits and `_`, starting with a letter'))
300
+ .max(10)
301
+ .refine((names) => new Set(names).size === names.length, 'secret names must be unique')
302
+ .optional(),
303
+ /** `entries` shows the source's entries open, in a herd block of its own; not for a ticket source, whose entries are badges. */
304
+ show: z.literal(SOURCE_SHOW.entries).optional(),
292
305
  })
293
306
  .refine(canChange, {
294
307
  message: 'map needs updatedAt, a content field (title, author, body, url) or reportOn',
@@ -298,6 +311,10 @@ const sourceSchema = z
298
311
  message: 'ignoreAuthors needs map.author',
299
312
  path: ['ignoreAuthors'],
300
313
  })
314
+ .refine((source) => source.show === undefined || source.ticket !== true, {
315
+ message: 'show cannot be set on a ticket source',
316
+ path: ['show'],
317
+ })
301
318
  .refine((source) => !source.reportOn?.includes(SOURCE_FIELD.id), {
302
319
  message: 'reportOn cannot name id',
303
320
  path: ['reportOn'],
@@ -592,6 +609,18 @@ const parseLabelled = <T extends z.ZodType>(schema: T, raw: unknown, label: stri
592
609
  export const parseConfigFile = (raw: unknown, label: string): JackherdConfig =>
593
610
  parseLabelled(jackherdConfigSchema, raw, label)
594
611
 
612
+ /** The personal sources file (`~/.jackherd/sources.json`): the same entries as `watch.sources`. */
613
+ export const personalSourcesSchema = z.strictObject({
614
+ $schema: z.string().optional(),
615
+ sources: z.array(sourceSchema).refine(hasUniqueNames, 'source names must be unique').default([]),
616
+ })
617
+
618
+ export type PersonalSourcesFile = z.infer<typeof personalSourcesSchema>
619
+
620
+ /** The personal sources file as read from JSON; invalid throws with `label` (its path) and the issues in words. */
621
+ export const parsePersonalSourcesFile = (raw: unknown, label: string): PersonalSourcesFile =>
622
+ parseLabelled(personalSourcesSchema, raw, label)
623
+
595
624
  /** One `watch.sources` entry outside the config, such as a draft to try; invalid throws with `label` and the issues. */
596
625
  export const parseSourceDraft = (raw: unknown, label: string): z.infer<typeof sourceSchema> =>
597
626
  parseLabelled(sourceSchema, raw, label)
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { readFileSync, readlinkSync, unlinkSync } from 'node:fs'
2
+ import { unlinkSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
4
  import { ensurePrivateDirectory, writePrivateFile } from '../../kit/hooks/hooks/shared/private-directory'
5
5
  import {
@@ -9,6 +9,7 @@ import {
9
9
  systemLocation,
10
10
  userDirectoryOf,
11
11
  } from '../shared/private-location'
12
+ import { systemProcessInfo } from '../shared/process-info'
12
13
  import { HERD_COMMAND } from './herd-command'
13
14
  import { CHILD_MARKER, TAKEN_OVER_CODE, TAKEOVER_SIGNAL } from './restart-decision'
14
15
 
@@ -96,36 +97,13 @@ export const startExclusive = async (
96
97
  start()
97
98
  }
98
99
 
99
- const terminalOfStdin = (): string | null => {
100
- try {
101
- const target = readlinkSync('/proc/self/fd/0')
102
- return target.startsWith('/dev/') ? target : null
103
- } catch {
104
- return null
105
- }
106
- }
100
+ /** True when the words of a process hold the herd child's marker and the herd command. */
101
+ export const isHerdViewWords = (words: readonly string[] | null): boolean =>
102
+ Boolean(words?.includes(`${CHILD_MARKER}=1`) && words.includes(HERD_COMMAND.name))
107
103
 
108
- const readText = (path: string): string | null => {
109
- try {
110
- return readFileSync(path, 'utf8')
111
- } catch {
112
- return null
113
- }
114
- }
104
+ const processInfo = systemProcessInfo()
115
105
 
116
- const isHerdViewProcess = (pid: number): boolean => {
117
- const environment = readText(`/proc/${pid}/environ`)
118
- const command = readText(`/proc/${pid}/cmdline`)
119
- if (environment === null || command === null) return false
120
- return environment.split('\0').includes(`${CHILD_MARKER}=1`) && command.split('\0').includes(HERD_COMMAND.name)
121
- }
122
-
123
- const isGoneProcess = (pid: number): boolean => {
124
- const stat = readText(`/proc/${pid}/stat`)
125
- if (stat === null) return true
126
- const stateIndex = stat.lastIndexOf(')') + 2
127
- return stat.charAt(stateIndex) === 'Z'
128
- }
106
+ const isHerdViewProcess = (pid: number): boolean => isHerdViewWords(processInfo.wordsOf(pid))
129
107
 
130
108
  const signalProcess = (pid: number, signal: NodeJS.Signals): void => {
131
109
  try {
@@ -158,7 +136,7 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
158
136
  }
159
137
  },
160
138
  isHerdView: isHerdViewProcess,
161
- isGone: isGoneProcess,
139
+ isGone: processInfo.isGone,
162
140
  signal: signalProcess,
163
141
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
164
142
  onExit: (run) => {
@@ -168,6 +146,6 @@ export const herdClaimDeps = (file: string): ClaimDeps => ({
168
146
 
169
147
  /** Takes the terminal of stdin over from an older herd view; without a terminal (or `/proc`) it does nothing. */
170
148
  export const claimOwnTerminal = async (): Promise<void> => {
171
- const terminal = terminalOfStdin()
149
+ const terminal = processInfo.terminalOfStdin()
172
150
  if (terminal !== null) await claimTerminal(herdClaimDeps(recordFileOf(terminal)))
173
151
  }