@synap-core/cli 1.13.0 → 1.13.1-next.20261102.246dc73

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 (227) hide show
  1. package/README.md +13 -3
  2. package/dist/build-info.json +2 -3
  3. package/dist/commands/agents.js +12 -2
  4. package/dist/commands/agents.js.map +1 -1
  5. package/dist/commands/capability.d.ts +86 -0
  6. package/dist/commands/capability.js +117 -30
  7. package/dist/commands/capability.js.map +1 -1
  8. package/dist/commands/context.js +2 -1
  9. package/dist/commands/context.js.map +1 -1
  10. package/dist/commands/data-extra.d.ts +13 -0
  11. package/dist/commands/data-extra.js +23 -2
  12. package/dist/commands/data-extra.js.map +1 -1
  13. package/dist/commands/doctor.d.ts +18 -0
  14. package/dist/commands/doctor.js +28 -1
  15. package/dist/commands/doctor.js.map +1 -1
  16. package/dist/commands/init.js +4 -2
  17. package/dist/commands/init.js.map +1 -1
  18. package/dist/commands/keys-request.d.ts +48 -0
  19. package/dist/commands/keys-request.js +236 -0
  20. package/dist/commands/keys-request.js.map +1 -0
  21. package/dist/commands/keys-request.test.d.ts +1 -0
  22. package/dist/commands/keys-request.test.js +176 -0
  23. package/dist/commands/keys-request.test.js.map +1 -0
  24. package/dist/commands/knowledge.d.ts +42 -5
  25. package/dist/commands/knowledge.js +90 -27
  26. package/dist/commands/knowledge.js.map +1 -1
  27. package/dist/commands/market.d.ts +89 -31
  28. package/dist/commands/market.js +215 -222
  29. package/dist/commands/market.js.map +1 -1
  30. package/dist/commands/mcp.d.ts +1 -1
  31. package/dist/commands/mcp.js +14 -9
  32. package/dist/commands/mcp.js.map +1 -1
  33. package/dist/commands/pods.js +6 -2
  34. package/dist/commands/pods.js.map +1 -1
  35. package/dist/commands/proposals-actions.js +7 -1
  36. package/dist/commands/proposals-actions.js.map +1 -1
  37. package/dist/commands/providers.d.ts +22 -6
  38. package/dist/commands/providers.js +39 -32
  39. package/dist/commands/providers.js.map +1 -1
  40. package/dist/commands/raycast.d.ts +15 -0
  41. package/dist/commands/raycast.js +36 -10
  42. package/dist/commands/raycast.js.map +1 -1
  43. package/dist/commands/status.js +12 -6
  44. package/dist/commands/status.js.map +1 -1
  45. package/dist/commands/statusline.backoff.test.js +13 -3
  46. package/dist/commands/statusline.backoff.test.js.map +1 -1
  47. package/dist/commands/statusline.d.ts +104 -9
  48. package/dist/commands/statusline.js +392 -148
  49. package/dist/commands/statusline.js.map +1 -1
  50. package/dist/commands/statusline.line2.test.d.ts +15 -0
  51. package/dist/commands/statusline.line2.test.js +226 -0
  52. package/dist/commands/statusline.line2.test.js.map +1 -0
  53. package/dist/commands/statusline.needs-you.test.d.ts +1 -0
  54. package/dist/commands/statusline.needs-you.test.js +17 -0
  55. package/dist/commands/statusline.needs-you.test.js.map +1 -0
  56. package/dist/commands/statusline.now.test.d.ts +10 -0
  57. package/dist/commands/statusline.now.test.js +250 -0
  58. package/dist/commands/statusline.now.test.js.map +1 -0
  59. package/dist/commands/tracks.d.ts +1 -0
  60. package/dist/commands/tracks.js +4 -0
  61. package/dist/commands/tracks.js.map +1 -1
  62. package/dist/commands/upload.js +12 -0
  63. package/dist/commands/upload.js.map +1 -1
  64. package/dist/commands/wait.d.ts +124 -0
  65. package/dist/commands/wait.js +384 -0
  66. package/dist/commands/wait.js.map +1 -0
  67. package/dist/commands/wait.test.d.ts +8 -0
  68. package/dist/commands/wait.test.js +308 -0
  69. package/dist/commands/wait.test.js.map +1 -0
  70. package/dist/commands/worker-github-app.d.ts +63 -0
  71. package/dist/commands/worker-github-app.js +161 -0
  72. package/dist/commands/worker-github-app.js.map +1 -0
  73. package/dist/commands/worker-github-app.test.d.ts +1 -0
  74. package/dist/commands/worker-github-app.test.js +102 -0
  75. package/dist/commands/worker-github-app.test.js.map +1 -0
  76. package/dist/commands/worker-github-broker.d.ts +39 -0
  77. package/dist/commands/worker-github-broker.js +106 -0
  78. package/dist/commands/worker-github-broker.js.map +1 -0
  79. package/dist/commands/worker-github-broker.test.d.ts +1 -0
  80. package/dist/commands/worker-github-broker.test.js +90 -0
  81. package/dist/commands/worker-github-broker.test.js.map +1 -0
  82. package/dist/commands/worker-runs.test.d.ts +1 -0
  83. package/dist/commands/worker-runs.test.js +150 -0
  84. package/dist/commands/worker-runs.test.js.map +1 -0
  85. package/dist/commands/worker-worktrees.d.ts +34 -0
  86. package/dist/commands/worker-worktrees.js +87 -0
  87. package/dist/commands/worker-worktrees.js.map +1 -0
  88. package/dist/commands/worker.d.ts +192 -5
  89. package/dist/commands/worker.js +467 -45
  90. package/dist/commands/worker.js.map +1 -1
  91. package/dist/commands/worker.test.js +269 -3
  92. package/dist/commands/worker.test.js.map +1 -1
  93. package/dist/index.js +59 -4
  94. package/dist/index.js.map +1 -1
  95. package/dist/lib/agents-config.d.ts +9 -0
  96. package/dist/lib/agents-config.js +20 -0
  97. package/dist/lib/agents-config.js.map +1 -1
  98. package/dist/lib/atomic-file.d.ts +20 -1
  99. package/dist/lib/atomic-file.js +100 -2
  100. package/dist/lib/atomic-file.js.map +1 -1
  101. package/dist/lib/auth.d.ts +25 -0
  102. package/dist/lib/auth.js +49 -9
  103. package/dist/lib/auth.js.map +1 -1
  104. package/dist/lib/box/job-server.js +4 -0
  105. package/dist/lib/box/job-server.js.map +1 -1
  106. package/dist/lib/bridge-invocation.test.d.ts +1 -0
  107. package/dist/lib/bridge-invocation.test.js +75 -0
  108. package/dist/lib/bridge-invocation.test.js.map +1 -0
  109. package/dist/lib/bridge-status.d.ts +144 -0
  110. package/dist/lib/bridge-status.js +227 -0
  111. package/dist/lib/bridge-status.js.map +1 -0
  112. package/dist/lib/bridge-status.test.d.ts +6 -0
  113. package/dist/lib/bridge-status.test.js +197 -0
  114. package/dist/lib/bridge-status.test.js.map +1 -0
  115. package/dist/lib/client-header.test.js +19 -1
  116. package/dist/lib/client-header.test.js.map +1 -1
  117. package/dist/lib/conversation-headers.d.ts +67 -2
  118. package/dist/lib/conversation-headers.js +122 -6
  119. package/dist/lib/conversation-headers.js.map +1 -1
  120. package/dist/lib/conversation-headers.test.d.ts +1 -0
  121. package/dist/lib/conversation-headers.test.js +78 -0
  122. package/dist/lib/conversation-headers.test.js.map +1 -0
  123. package/dist/lib/credential-class.d.ts +12 -1
  124. package/dist/lib/credential-class.js +20 -0
  125. package/dist/lib/credential-class.js.map +1 -1
  126. package/dist/lib/device-connect.d.ts +45 -0
  127. package/dist/lib/device-connect.js +60 -0
  128. package/dist/lib/device-connect.js.map +1 -1
  129. package/dist/lib/goal-words.js +6 -3
  130. package/dist/lib/goal-words.js.map +1 -1
  131. package/dist/lib/hub-client.d.ts +59 -1
  132. package/dist/lib/hub-client.js +130 -36
  133. package/dist/lib/hub-client.js.map +1 -1
  134. package/dist/lib/key-locations.d.ts +3 -3
  135. package/dist/lib/key-locations.js +3 -39
  136. package/dist/lib/key-locations.js.map +1 -1
  137. package/dist/lib/machine-instance.d.ts +7 -5
  138. package/dist/lib/machine-instance.js +7 -5
  139. package/dist/lib/machine-instance.js.map +1 -1
  140. package/dist/lib/mcp-bridge.d.ts +43 -0
  141. package/dist/lib/mcp-bridge.js +199 -11
  142. package/dist/lib/mcp-bridge.js.map +1 -1
  143. package/dist/lib/mcp-bridge.test.js +194 -2
  144. package/dist/lib/mcp-bridge.test.js.map +1 -1
  145. package/dist/lib/mcp-call.d.ts +5 -1
  146. package/dist/lib/mcp-call.js +7 -6
  147. package/dist/lib/mcp-call.js.map +1 -1
  148. package/dist/lib/mcp-connector-url.test.d.ts +1 -0
  149. package/dist/lib/mcp-connector-url.test.js +13 -0
  150. package/dist/lib/mcp-connector-url.test.js.map +1 -0
  151. package/dist/lib/mcp-snippets.d.ts +16 -0
  152. package/dist/lib/mcp-snippets.js +20 -2
  153. package/dist/lib/mcp-snippets.js.map +1 -1
  154. package/dist/lib/now-line.d.ts +66 -0
  155. package/dist/lib/now-line.js +193 -0
  156. package/dist/lib/now-line.js.map +1 -0
  157. package/dist/lib/now-line.test.d.ts +5 -0
  158. package/dist/lib/now-line.test.js +177 -0
  159. package/dist/lib/now-line.test.js.map +1 -0
  160. package/dist/lib/package-updates.d.ts +66 -0
  161. package/dist/lib/package-updates.js +76 -0
  162. package/dist/lib/package-updates.js.map +1 -0
  163. package/dist/lib/pod.d.ts +0 -4
  164. package/dist/lib/pod.js +0 -7
  165. package/dist/lib/pod.js.map +1 -1
  166. package/dist/lib/register-claude-code-mcp.test.js +19 -0
  167. package/dist/lib/register-claude-code-mcp.test.js.map +1 -1
  168. package/dist/lib/safe-open.d.ts +5 -3
  169. package/dist/lib/safe-open.js +9 -8
  170. package/dist/lib/safe-open.js.map +1 -1
  171. package/dist/lib/self-host.d.ts +17 -0
  172. package/dist/lib/self-host.js +24 -0
  173. package/dist/lib/self-host.js.map +1 -0
  174. package/dist/lib/targets.d.ts +31 -0
  175. package/dist/lib/targets.js +100 -104
  176. package/dist/lib/targets.js.map +1 -1
  177. package/dist/lib/template-lifecycle.d.ts +0 -31
  178. package/dist/lib/template-lifecycle.js +0 -74
  179. package/dist/lib/template-lifecycle.js.map +1 -1
  180. package/dist/lib/term.d.ts +24 -0
  181. package/dist/lib/term.js +52 -0
  182. package/dist/lib/term.js.map +1 -0
  183. package/dist/lib/term.test.d.ts +1 -0
  184. package/dist/lib/term.test.js +29 -0
  185. package/dist/lib/term.test.js.map +1 -0
  186. package/dist/lib/transcript-index.d.ts +94 -0
  187. package/dist/lib/transcript-index.js +321 -0
  188. package/dist/lib/transcript-index.js.map +1 -0
  189. package/dist/lib/transcript-index.test.d.ts +10 -0
  190. package/dist/lib/transcript-index.test.js +200 -0
  191. package/dist/lib/transcript-index.test.js.map +1 -0
  192. package/dist/lib/wait-help.d.ts +11 -0
  193. package/dist/lib/wait-help.js +25 -0
  194. package/dist/lib/wait-help.js.map +1 -0
  195. package/dist/lib/wait-state.d.ts +57 -0
  196. package/dist/lib/wait-state.js +114 -0
  197. package/dist/lib/wait-state.js.map +1 -0
  198. package/package.json +2 -2
  199. package/skills/synap/SKILL.md +81 -44
  200. package/skills/synap/code-skills.md +15 -1
  201. package/skills/synap/concepts.md +36 -26
  202. package/skills/synap/document-embeds.md +15 -1
  203. package/skills/synap/focus-sessions.md +4 -4
  204. package/skills/synap/from-intent.md +2 -2
  205. package/skills/synap/governance-rules.md +2 -0
  206. package/skills/synap/hand-off.md +10 -6
  207. package/skills/synap/reflexes.md +4 -4
  208. package/skills/synap/work-model.md +1 -1
  209. package/skills/synap/writing-titles.md +7 -0
  210. package/skills/synap-app/SKILL.md +2 -2
  211. package/skills/synap-app/overview.md +2 -2
  212. package/skills/synap-market/SKILL.md +1 -1
  213. package/skills/synap-market/governance-and-catalog.md +1 -1
  214. package/skills/synap-schema/SKILL.md +4 -1
  215. package/skills/synap-schema/intent-vocabulary-ssot.md +4 -1
  216. package/dist/commands/template.d.ts +0 -42
  217. package/dist/commands/template.js +0 -191
  218. package/dist/commands/template.js.map +0 -1
  219. package/dist/lib/browser-auth.d.ts +0 -35
  220. package/dist/lib/browser-auth.js +0 -170
  221. package/dist/lib/browser-auth.js.map +0 -1
  222. package/dist/lib/openclaw.d.ts +0 -32
  223. package/dist/lib/openclaw.js +0 -165
  224. package/dist/lib/openclaw.js.map +0 -1
  225. package/dist/lib/seed.d.ts +0 -13
  226. package/dist/lib/seed.js +0 -135
  227. package/dist/lib/seed.js.map +0 -1
@@ -1,18 +1,33 @@
1
1
  /**
2
- * synap statusline — full 3-line ANSI status for the Claude Code statusLine.
2
+ * synap statusline — full 4-line ANSI status for the Claude Code statusLine.
3
3
  *
4
4
  * Two modes, both via `synap statusline`:
5
5
  *
6
6
  * RENDER (default) Parse the Claude Code JSON from stdin, read the pod
7
- * cache file (NO network), compose 3 ANSI lines, print,
8
- * exit. Always instant. If the cache is stale it spawns a
9
- * detached refresh (fire-and-forget) so the NEXT render is
10
- * fresh — this render never blocks on the network.
7
+ * cache files and local state (NO network), compose 4
8
+ * ANSI lines, print, exit. Always instant. If a cache is
9
+ * stale it spawns a detached refresh (fire-and-forget) so
10
+ * the NEXT render is fresh — this render never blocks on
11
+ * the network.
11
12
  *
12
- * REFRESH (--refresh) Fetch pod data in a single parallel burst and write the
13
- * cache. Lock-guarded so only one refresh runs at a time.
14
- * On error/429, the last good cache is preserved (the dot
15
- * goes stale, not blank).
13
+ * REFRESH (--refresh) Fetch pod data in parallel bursts and write the caches.
14
+ * Lock-guarded so only one refresh of each cache runs at a
15
+ * time. On error/429, the last good cache is preserved
16
+ * (the dot goes stale, not blank). Also catches the
17
+ * conversation's transcript index up (local, no network).
18
+ *
19
+ * Two pod caches: the pod-WIDE fields (workspaces, skills, needs-you, identity,
20
+ * entity totals) in ONE shared file, refreshed at most once per REFRESH_TTL
21
+ * whatever the number of conversations; the CONVERSATION's own fields (its lens
22
+ * workspace, bound session, project) in a file per Claude Code session id, so
23
+ * a conversation never shows the session another conversation refreshed last
24
+ * (each one used to, when all of it lived in the one shared file).
25
+ *
26
+ * The 4th line says what the conversation is doing now and what it waits on:
27
+ * first from the synap-bridge plugin's per-conversation status file (what the
28
+ * pod says, lib/bridge-status.ts), else from Claude Code's process registry,
29
+ * the transcript index (lib/transcript-index.ts) and `synap wait` state files
30
+ * (lib/now-line.ts) — all local files.
16
31
  *
17
32
  * Why: Claude Code calls the statusline on every render (debounced ~300ms +
18
33
  * periodic refresh). Fetching live pod data synchronously per render hammers
@@ -21,34 +36,40 @@
21
36
  */
22
37
  import fs from "node:fs";
23
38
  import os from "node:os";
24
- import { execSync, spawn } from "node:child_process";
39
+ import path from "node:path";
40
+ import { execFileSync, spawn } from "node:child_process";
25
41
  import { resolveHubConfig, hubGet } from "../lib/hub-client.js";
26
42
  import { resolveActiveLens } from "../lib/session-lens.js";
27
43
  import { describeLens } from "../lib/describe-lens.js";
28
44
  import { getSurfaceAgentKey, SURFACE_NAMES } from "../lib/pod.js";
29
- const CACHE_FILE = "/tmp/synap-statusline-cache.json";
45
+ import { age, buildNowLine, readRegistry } from "../lib/now-line.js";
46
+ import { buildBridgeLine, readBridgeStatus } from "../lib/bridge-status.js";
47
+ import { defaultTranscriptPath, indexLockPath, loadIndex, saveIndex, updateIndex, } from "../lib/transcript-index.js";
48
+ import { pidAlive, pruneWaits, readWaits } from "../lib/wait-state.js";
49
+ import { BOLD_BLUE as BB, BOLD_GREEN as G, BOLD_RED as R, BOLD_YELLOW as Y, CYAN as C, DIM as D, MAGENTA as M, RESET as X, osc8, safeText, } from "../lib/term.js";
50
+ const CACHE_FILE = "/tmp/synap-statusline-cache.json"; // pod-wide fields, shared by every conversation
30
51
  const LOCK_FILE = "/tmp/synap-statusline-refresh.lock";
52
+ /** A session id as a file-name part. */
53
+ function convKey(claudeSessionId) {
54
+ return (claudeSessionId ?? "").replace(/[^A-Za-z0-9_-]/g, "") || "_nosession";
55
+ }
56
+ export function convCacheFile(claudeSessionId) {
57
+ return `/tmp/synap-statusline-conv-${convKey(claudeSessionId)}.json`;
58
+ }
59
+ function convLockFile(claudeSessionId) {
60
+ return `/tmp/synap-statusline-conv-${convKey(claudeSessionId)}.lock`;
61
+ }
62
+ /** Render reads at most this many new transcript bytes; more is left to the detached refresh. */
63
+ const RENDER_TRANSCRIPT_BYTES = 2 * 1024 * 1024;
31
64
  // The pod sits behind a strict edge rate-limiter (15-min window) shared with
32
65
  // the agent's own traffic on the same key. Keep statusline cost minimal:
33
66
  // refresh at most once per 120s; the burst is ~4-6 GETs (workspaces, skills,
34
- // session, proposals, + conditional project/identity). Fold new fields into an
67
+ // session, needs-you, + conditional project/identity). Fold new fields into an
35
68
  // existing call rather than adding to the burst.
36
69
  const REFRESH_TTL_MS = 120_000;
37
70
  const LOCK_STALE_MS = 30_000; // a lock older than this is considered dead
38
- // ── ANSI + OSC 8 helpers ───────────────────────────────────────────────
39
- const G = "\x1b[01;32m"; // green bold
40
- const Y = "\x1b[01;33m"; // yellow bold
41
- const R = "\x1b[01;31m"; // red bold
42
- const C = "\x1b[36m"; // cyan
43
- const BB = "\x1b[01;34m"; // bold blue — used for the pinned PROJECT (prominence)
44
- const M = "\x1b[35m"; // magenta
45
- const D = "\x1b[2m"; // dim
46
- const X = "\x1b[0m"; // reset
47
- function osc8(url, text) {
48
- if (!url)
49
- return text;
50
- return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
51
- }
71
+ // ── Colour helpers (codes and OSC 8 in lib/term.ts) ────────────────────
72
+ // BB, bold blue, is the pinned PROJECT's colour (prominence).
52
73
  function ctxColor(pct) {
53
74
  if (pct >= 80)
54
75
  return R;
@@ -74,10 +95,13 @@ function compact(n) {
74
95
  return `${(n / 1000).toFixed(0)}K`;
75
96
  return String(n);
76
97
  }
98
+ function emptyConv() {
99
+ return { ts: 0, attemptedTs: 0, ok: false, podUrl: "", activeWorkspaceId: "", projectName: "", projectId: "", sessionGoal: "", sessionId: "" };
100
+ }
77
101
  function emptyCache() {
78
102
  return {
79
103
  ts: 0, attemptedTs: 0, ok: false, podUrl: "", activeWorkspaceId: "", workspaces: [], projectName: "", projectId: "",
80
- skillCount: 0, proposalCount: 0, proposalsFetched: false, sessionGoal: "", sessionId: "", totalEntities: 0,
104
+ skillCount: 0, needsYouCount: 0, needsYouTruncated: false, needsYouFetched: false, sessionGoal: "", sessionId: "", totalEntities: 0,
81
105
  actingAgentLabel: "", identityMismatch: false,
82
106
  };
83
107
  }
@@ -89,33 +113,55 @@ function findSurfaceLabelForKey(hubApiKey) {
89
113
  }
90
114
  return null;
91
115
  }
92
- function readCache() {
116
+ function readJson(file) {
93
117
  try {
94
- return JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
118
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
95
119
  }
96
120
  catch {
97
121
  return null;
98
122
  }
99
123
  }
124
+ /** Missing, or its last attempt is older than the TTL. The backoff clock is the last ATTEMPT, not the last success (see PodCache). */
125
+ function isDue(c) {
126
+ return !c || Date.now() - (c.attemptedTs ?? c.ts) > REFRESH_TTL_MS;
127
+ }
100
128
  // ── Detached refresh trigger (fire-and-forget) ─────────────────────────
101
- function lockIsFresh() {
129
+ function lockIsFresh(file = LOCK_FILE) {
102
130
  try {
103
- return Date.now() - fs.statSync(LOCK_FILE).mtimeMs < LOCK_STALE_MS;
131
+ return Date.now() - fs.statSync(file).mtimeMs < LOCK_STALE_MS;
104
132
  }
105
133
  catch {
106
134
  return false;
107
135
  }
108
136
  }
109
- function triggerRefresh(claudeSessionId) {
110
- if (lockIsFresh())
111
- return; // a refresh is already running
137
+ function takeLock(file) {
138
+ if (lockIsFresh(file))
139
+ return false; // another refresh owns the window
140
+ try {
141
+ fs.writeFileSync(file, String(process.pid));
142
+ }
143
+ catch {
144
+ /* ignore */
145
+ }
146
+ return true;
147
+ }
148
+ function dropLock(file) {
149
+ try {
150
+ fs.unlinkSync(file);
151
+ }
152
+ catch { }
153
+ }
154
+ function triggerRefresh(claudeSessionId, transcriptPath) {
112
155
  try {
113
156
  const entry = process.argv[1]; // the CLI entry script
114
157
  // The detached refresh has no Claude env of its own — forward the session
115
- // id (from stdin) so its resolveHubConfig/lens picks the right workspace.
116
- const env = claudeSessionId
117
- ? { ...process.env, SYNAP_LENS_SESSION: claudeSessionId }
118
- : process.env;
158
+ // id (from stdin) so its resolveHubConfig/lens picks the right workspace,
159
+ // and the transcript path so it can catch the index up.
160
+ const env = { ...process.env };
161
+ if (claudeSessionId)
162
+ env.SYNAP_LENS_SESSION = claudeSessionId;
163
+ if (transcriptPath)
164
+ env.SYNAP_STATUSLINE_TRANSCRIPT = transcriptPath;
119
165
  const child = spawn(process.execPath, [entry, "statusline", "--refresh"], {
120
166
  detached: true,
121
167
  stdio: "ignore",
@@ -128,45 +174,89 @@ function triggerRefresh(claudeSessionId) {
128
174
  }
129
175
  }
130
176
  // ── REFRESH mode: fetch pod, write cache (lock-guarded) ────────────────
131
- async function refresh() {
132
- if (lockIsFresh())
133
- return; // another refresh owns the window
177
+ /** The conversation this process serves: forwarded by render as SYNAP_LENS_SESSION. */
178
+ function refreshSessionId() {
179
+ return process.env.SYNAP_LENS_SESSION || process.env.CLAUDE_CODE_SESSION_ID || undefined;
180
+ }
181
+ /**
182
+ * Catch the transcript index up (local files only), streaming: render reads at
183
+ * most RENDER_TRANSCRIPT_BYTES, so the first scan of a big transcript happens
184
+ * here. Also prunes this conversation's finished `synap wait` files.
185
+ */
186
+ function catchUpTranscript(claudeSessionId) {
187
+ const transcriptPath = process.env.SYNAP_STATUSLINE_TRANSCRIPT;
188
+ if (!claudeSessionId)
189
+ return;
134
190
  try {
135
- fs.writeFileSync(LOCK_FILE, String(process.pid));
191
+ pruneWaits(claudeSessionId);
192
+ }
193
+ catch { }
194
+ if (!transcriptPath)
195
+ return;
196
+ const lock = indexLockPath(claudeSessionId);
197
+ try {
198
+ fs.mkdirSync(path.dirname(lock), { recursive: true });
199
+ }
200
+ catch { }
201
+ if (!takeLock(lock))
202
+ return;
203
+ try {
204
+ const { index, changed } = updateIndex(loadIndex(claudeSessionId), transcriptPath, { maxBytes: Infinity });
205
+ if (changed)
206
+ saveIndex(claudeSessionId, index);
136
207
  }
137
208
  catch {
138
- /* ignore */
209
+ /* the index is a display aid: the next render retries */
210
+ }
211
+ finally {
212
+ dropLock(lock);
213
+ }
214
+ }
215
+ async function refresh() {
216
+ const claudeSessionId = refreshSessionId();
217
+ catchUpTranscript(claudeSessionId);
218
+ const convFile = convCacheFile(claudeSessionId);
219
+ const convLock = convLockFile(claudeSessionId);
220
+ const prevShared = readJson(CACHE_FILE);
221
+ const prevConv = readJson(convFile);
222
+ // Each cache is fetched only when IT is due and nobody else is fetching it:
223
+ // N conversations share one pod-wide burst per TTL, plus their own 1–2 GETs.
224
+ const doShared = isDue(prevShared) && takeLock(LOCK_FILE);
225
+ const doConv = isDue(prevConv) && takeLock(convLock);
226
+ if (!doShared && !doConv)
227
+ return;
228
+ try {
229
+ const cfg = await resolveHubConfig({}).catch(() => null);
230
+ await Promise.all([
231
+ doShared ? refreshShared(cfg, prevShared) : null,
232
+ doConv ? refreshConv(cfg, prevConv, convFile) : null,
233
+ ]);
234
+ }
235
+ finally {
236
+ if (doShared)
237
+ dropLock(LOCK_FILE);
238
+ if (doConv)
239
+ dropLock(convLock);
139
240
  }
140
- const prev = readCache();
241
+ }
242
+ /**
243
+ * The needs-you number out of a `GET /needs-you` body: `count.needsYou` is THE
244
+ * rule's headline. Anything else (no `count`, a non-number) is null = not
245
+ * measured, never 0.
246
+ */
247
+ export function parseNeedsYouCount(body) {
248
+ const c = body?.count;
249
+ if (typeof c?.needsYou !== "number" || !Number.isFinite(c.needsYou))
250
+ return null;
251
+ return { count: c.needsYou, truncated: c.truncated === true };
252
+ }
253
+ /** The pod-wide burst: workspaces (doubling as liveness), skills, identity, proposals. */
254
+ async function refreshShared(cfg, prev) {
141
255
  try {
142
- const cfg = await resolveHubConfig({});
143
256
  // Thrown, not returned: the catch below stamps the attempt, so an
144
257
  // unconfigured CLI also backs off instead of respawning every render.
145
- if (!cfg.podUrl || !cfg.apiKey)
258
+ if (!cfg?.podUrl || !cfg.apiKey)
146
259
  throw new Error("statusline: no pod config");
147
- // This connection's per-Claude-session lens (forwarded as SYNAP_LENS_SESSION
148
- // by the render process). Drives WHICH project/session we show — the bound
149
- // ones, not a global guess.
150
- const lens = resolveActiveLens();
151
- // Minimal burst (the pod's edge limiter is shared with agent traffic).
152
- // `/workspaces` succeeding doubles as the liveness signal — no separate
153
- // `/health`. The bound focus-session and project are fetched by id only
154
- // when the lens has them (0–2 extra calls).
155
- const sessionReq = lens?.focusSessionId
156
- ? hubGet(`/focus-sessions/${lens.focusSessionId}`, cfg.workspaceId ? { workspaceId: cfg.workspaceId } : {}, cfg)
157
- : hubGet("/focus-sessions?status=active&limit=1", {}, cfg);
158
- // Use the FULLY-RESOLVED project id (cfg.projectId = session-lens ?? env ??
159
- // durable activeProjectId), NOT just the session lens — so a durable
160
- // `synap project use` shows even in a fresh session with no lens file yet.
161
- // Mirrors how activeWorkspaceId already uses cfg.workspaceId below.
162
- const resolvedProjectId = cfg.projectId;
163
- // Resolve the NAME via the single-project route. `GET /projects` returns a
164
- // bare array and ignores `?ids=` (only status/limit) — the old call read a
165
- // nonexistent `.projects` field, so the name never resolved and the line
166
- // showed the raw UUID. `/projects/:id` returns the row directly ({id,name}).
167
- const projectReq = resolvedProjectId
168
- ? hubGet(`/projects/${encodeURIComponent(resolvedProjectId)}`, {}, cfg)
169
- : Promise.resolve(null);
170
260
  // Identity safety: compare the AMBIENT SYNAP_HUB_API_KEY against the
171
261
  // claude-code surface key pinned to this pod. A cheap string compare
172
262
  // covers the common case (no pod call). Only when they diverge do we pay
@@ -182,19 +272,19 @@ async function refresh() {
182
272
  const identityReq = needsIdentityCheck
183
273
  ? hubGet("/users/me", {}, { podUrl: cfg.podUrl, apiKey: ambientKey, userId: "cli" })
184
274
  : Promise.resolve(null);
185
- // Pending proposals — the one actionable ambient signal. No dedicated
186
- // count endpoint exists yet (verified: `/proposals` returns `{ proposals: [] }`,
187
- // no `total`); a high `limit` on the pending-only list is the cheapest
188
- // accurate count within the burst budget (a personal pod won't realistically
189
- // clear 1000 pending proposals before this undercounts).
190
- const proposalsReq = hubGet("/proposals", { status: "pending", limit: 1000 }, cfg);
191
- const [ws, skills, session, project, identity, proposals] = await Promise.allSettled([
275
+ // Needs you — the one actionable ambient signal, counted by the pod's own
276
+ // rule (`GET /needs-you`, `signals.needsYou`; decision E): the same number
277
+ // the badge shows, never re-derived from the pending-proposal list. `limit=1`
278
+ // trims the rows (we read only `count`).
279
+ const needsYouReq = hubGet("/needs-you", { limit: 1 }, cfg);
280
+ // Minimal burst (the pod's edge limiter is shared with agent traffic).
281
+ // `/workspaces` succeeding doubles as the liveness signal — no separate
282
+ // `/health`.
283
+ const [ws, skills, identity, needsYou] = await Promise.allSettled([
192
284
  hubGet("/workspaces", {}, cfg),
193
285
  hubGet("/agent-skills?limit=1", {}, cfg),
194
- sessionReq,
195
- projectReq,
196
286
  identityReq,
197
- proposalsReq,
287
+ needsYouReq,
198
288
  ]);
199
289
  // Reached the pod iff /workspaces returned a well-formed payload.
200
290
  const ok = ws.status === "fulfilled" && Array.isArray(ws.value?.workspaces);
@@ -208,26 +298,6 @@ async function refresh() {
208
298
  totalEntities += w.entityCount ?? 0;
209
299
  }
210
300
  const skillCount = skills.status === "fulfilled" ? (skills.value?.total ?? 0) : prev?.skillCount ?? 0;
211
- // Bound session — either the by-id fetch (object) or the active-list fallback.
212
- let sessionGoal = prev?.sessionGoal ?? "";
213
- let sessionId = prev?.sessionId ?? "";
214
- if (session.status === "fulfilled" && session.value) {
215
- const v = session.value;
216
- const s = v.sessions ? v.sessions[0] : v; // list-shape vs object-shape
217
- sessionGoal = s?.goal?.slice(0, 60) ?? "";
218
- sessionId = s?.id ?? "";
219
- }
220
- else if (lens?.focusSessionId) {
221
- sessionGoal = "";
222
- sessionId = "";
223
- }
224
- // Bound project (from the fully-resolved id — durable or session).
225
- let projectName = resolvedProjectId ? prev?.projectName ?? "" : "";
226
- let projectId = resolvedProjectId ?? "";
227
- if (project.status === "fulfilled" && project.value) {
228
- const p = project.value?.projects?.find((x) => x.id === resolvedProjectId);
229
- projectName = p?.name?.slice(0, 30) ?? "";
230
- }
231
301
  // Acting identity: clean unless we detected a real divergence.
232
302
  let actingAgentLabel = "";
233
303
  let identityMismatch = false;
@@ -238,29 +308,25 @@ async function refresh() {
238
308
  actingAgentLabel = (ambientKey && findSurfaceLabelForKey(ambientKey)) || actingId.slice(0, 8);
239
309
  }
240
310
  }
241
- // Only a well-shaped array counts as "fetched" — a malformed 200 must not
242
- // masquerade as "0 pending"; it falls back to the last-good count instead.
243
- const proposalsRaw = proposals.status === "fulfilled"
244
- ? proposals.value?.proposals
245
- : undefined;
246
- const proposalsFetched = Array.isArray(proposalsRaw);
247
- const proposalCount = Array.isArray(proposalsRaw)
248
- ? proposalsRaw.length
249
- : prev?.proposalCount ?? 0;
311
+ // A failed or malformed read keeps the last-good count AND its time, and
312
+ // says it failed (`needsYouOk: false`): render then shows it as old, never
313
+ // as fresh. Only a well-shaped `count` is a measurement.
314
+ const read = needsYou.status === "fulfilled" ? parseNeedsYouCount(needsYou.value) : null;
315
+ const needsYouCount = read ? read.count : prev?.needsYouCount ?? 0;
316
+ const needsYouTruncated = read ? read.truncated : prev?.needsYouTruncated ?? false;
317
+ const needsYouAt = read ? Date.now() : prev?.needsYouAt ?? 0;
250
318
  const out = {
251
319
  ts: Date.now(),
252
320
  attemptedTs: Date.now(),
253
321
  ok: true,
254
322
  podUrl: cfg.podUrl,
255
- activeWorkspaceId: cfg.workspaceId ?? "", // the lens THIS connection resolves to
256
323
  workspaces,
257
- projectName,
258
- projectId,
259
324
  skillCount,
260
- proposalCount,
261
- proposalsFetched: proposalsFetched || (prev?.proposalsFetched ?? false),
262
- sessionGoal,
263
- sessionId,
325
+ needsYouCount,
326
+ needsYouTruncated,
327
+ needsYouFetched: read !== null || (prev?.needsYouFetched ?? false),
328
+ needsYouAt,
329
+ needsYouOk: read !== null,
264
330
  totalEntities,
265
331
  actingAgentLabel,
266
332
  identityMismatch,
@@ -279,14 +345,114 @@ async function refresh() {
279
345
  }
280
346
  catch { }
281
347
  }
282
- finally {
348
+ }
349
+ /** This conversation's own fields: its lens workspace, bound session and project (0–2 GETs). */
350
+ async function refreshConv(cfg, prev, file) {
351
+ try {
352
+ if (!cfg?.podUrl || !cfg.apiKey)
353
+ throw new Error("statusline: no pod config");
354
+ // This connection's per-Claude-session lens (forwarded as SYNAP_LENS_SESSION
355
+ // by the render process). Drives WHICH project/session we show — the bound
356
+ // ones, not a global guess.
357
+ const lens = resolveActiveLens();
358
+ // The bound focus-session and project are fetched by id only when the
359
+ // lens has them.
360
+ const sessionReq = lens?.focusSessionId
361
+ ? hubGet(`/focus-sessions/${lens.focusSessionId}`, cfg.workspaceId ? { workspaceId: cfg.workspaceId } : {}, cfg)
362
+ : hubGet("/focus-sessions?status=active&limit=1", {}, cfg);
363
+ // Use the FULLY-RESOLVED project id (cfg.projectId = session-lens ?? env ??
364
+ // durable activeProjectId), NOT just the session lens — so a durable
365
+ // `synap project use` shows even in a fresh session with no lens file yet.
366
+ // Mirrors how activeWorkspaceId already uses cfg.workspaceId below.
367
+ const resolvedProjectId = cfg.projectId;
368
+ // Resolve the NAME via the single-project route. `GET /projects` returns a
369
+ // bare array and ignores `?ids=` (only status/limit) — the old call read a
370
+ // nonexistent `.projects` field, so the name never resolved and the line
371
+ // showed the raw UUID. `/projects/:id` returns the row directly ({id,name}).
372
+ const projectReq = resolvedProjectId
373
+ ? hubGet(`/projects/${encodeURIComponent(resolvedProjectId)}`, {}, cfg)
374
+ : Promise.resolve(null);
375
+ const [session, project] = await Promise.allSettled([sessionReq, projectReq]);
376
+ // The session read is this cache's liveness signal (as `/workspaces` is the
377
+ // shared one's): a 429 or a 5xx keeps the last good values (the catch
378
+ // stamps the attempt). Only a 404 on the bound session means it is gone.
379
+ const sessionGone = session.status === "rejected" && session.reason?.status === 404;
380
+ if (session.status === "rejected" && !sessionGone)
381
+ throw session.reason;
382
+ // Bound session — either the by-id fetch (object) or the active-list fallback.
383
+ let sessionGoal = prev?.sessionGoal ?? "";
384
+ let sessionId = prev?.sessionId ?? "";
385
+ if (session.status === "fulfilled" && session.value) {
386
+ const v = session.value;
387
+ const s = v.sessions ? v.sessions[0] : v; // list-shape vs object-shape
388
+ sessionGoal = s?.goal?.slice(0, 60) ?? "";
389
+ sessionId = s?.id ?? "";
390
+ }
391
+ else if (lens?.focusSessionId) {
392
+ sessionGoal = "";
393
+ sessionId = "";
394
+ }
395
+ // Bound project (from the fully-resolved id — durable or session).
396
+ let projectName = resolvedProjectId ? prev?.projectName ?? "" : "";
397
+ const projectId = resolvedProjectId ?? "";
398
+ if (project.status === "fulfilled" && project.value) {
399
+ // The single-row route answers the row itself ({ id, name, … }).
400
+ const name = project.value.name;
401
+ projectName = typeof name === "string" ? name.slice(0, 30) : "";
402
+ }
403
+ const out = {
404
+ ts: Date.now(),
405
+ attemptedTs: Date.now(),
406
+ ok: true,
407
+ podUrl: cfg.podUrl,
408
+ activeWorkspaceId: cfg.workspaceId ?? "", // the lens THIS connection resolves to
409
+ projectName,
410
+ projectId,
411
+ sessionGoal,
412
+ sessionId,
413
+ };
414
+ fs.writeFileSync(file, JSON.stringify(out));
415
+ }
416
+ catch {
417
+ // Same backoff as the shared cache: keep the last good values, stamp the attempt.
283
418
  try {
284
- fs.unlinkSync(LOCK_FILE);
419
+ fs.writeFileSync(file, JSON.stringify({ ...(prev ?? emptyConv()), ok: false, attemptedTs: Date.now() }));
285
420
  }
286
421
  catch { }
287
422
  }
288
423
  }
289
- // ── RENDER mode: stdin + cache → 3 lines (no network) ──────────────────
424
+ // ── RENDER mode: stdin + caches + local state → 4 lines (no network) ───
425
+ /**
426
+ * This conversation's transcript index, brought up to date with what was
427
+ * appended since the last render — at most RENDER_TRANSCRIPT_BYTES: a bigger
428
+ * backlog (the first look at a long conversation) is left to the detached
429
+ * refresh, and this render shows the index as it is. Returns whether that
430
+ * backlog is waiting.
431
+ */
432
+ function currentIndex(claudeSessionId, transcriptPath) {
433
+ try {
434
+ const { index, changed, behind } = updateIndex(loadIndex(claudeSessionId), transcriptPath, { maxBytes: RENDER_TRANSCRIPT_BYTES });
435
+ if (changed) {
436
+ try {
437
+ saveIndex(claudeSessionId, index);
438
+ }
439
+ catch { }
440
+ }
441
+ return { index, behind: behind > 0 };
442
+ }
443
+ catch {
444
+ return { index: null, behind: false };
445
+ }
446
+ }
447
+ /** Terminal width when Claude Code (or the shell) says it; the now line caps itself to it. */
448
+ function widthOf(cc) {
449
+ for (const v of [cc.terminal_width, cc.columns, cc.terminal?.columns, process.env.COLUMNS]) {
450
+ const n = Number(v);
451
+ if (Number.isFinite(n) && n > 0)
452
+ return n;
453
+ }
454
+ return undefined;
455
+ }
290
456
  function render() {
291
457
  let cc = {};
292
458
  try {
@@ -298,12 +464,38 @@ function render() {
298
464
  // The Claude session id — forwarded to the detached refresh so it resolves
299
465
  // THIS session's lens (workspace/project/session), not a global guess.
300
466
  const claudeSessionId = cc.session_id || process.env.CLAUDE_CODE_SESSION_ID;
301
- const cache = readCache();
302
- // Trigger a background refresh if cache is missing or stale.
303
- // The backoff clock is the last ATTEMPT, not the last success (see PodCache).
304
- if (!cache || Date.now() - (cache.attemptedTs ?? cache.ts) > REFRESH_TTL_MS)
305
- triggerRefresh(claudeSessionId);
306
- const pod = cache ?? emptyCache();
467
+ const cwd = (cc.cwd ?? "");
468
+ const transcriptPath = (typeof cc.transcript_path === "string" && cc.transcript_path) ||
469
+ (cwd && claudeSessionId ? defaultTranscriptPath(cwd, claudeSessionId) : "");
470
+ const shared = readJson(CACHE_FILE);
471
+ const conv = readJson(convCacheFile(claudeSessionId));
472
+ const { index, behind } = claudeSessionId && transcriptPath ? currentIndex(claudeSessionId, transcriptPath) : { index: null, behind: false };
473
+ // Trigger a background refresh if a cache is missing or stale, or the
474
+ // transcript backlog is too big for a render — unless that refresh is
475
+ // already running.
476
+ if ((isDue(shared) && !lockIsFresh(LOCK_FILE)) ||
477
+ (isDue(conv) && !lockIsFresh(convLockFile(claudeSessionId))) ||
478
+ (behind && claudeSessionId && !lockIsFresh(indexLockPath(claudeSessionId))))
479
+ triggerRefresh(claudeSessionId, transcriptPath);
480
+ // Pod-wide fields from the shared cache; the conversation's own ONLY from its
481
+ // file — a shared cache written by an older CLI carries another
482
+ // conversation's session and project, which must not show here.
483
+ const c = conv ?? emptyConv();
484
+ const base = { ...emptyCache(), ...(shared ?? {}) };
485
+ // Every pod string is scrubbed of control characters here, before any of it
486
+ // reaches the terminal (lib/term.ts): a name holding ESC or OSC 8 would
487
+ // otherwise recolour the line or plant a link of its own.
488
+ const pod = {
489
+ ...base,
490
+ workspaces: (Array.isArray(base.workspaces) ? base.workspaces : []).map((w) => ({ ...w, name: safeText(String(w.name ?? "")) })),
491
+ actingAgentLabel: safeText(String(base.actingAgentLabel ?? "")),
492
+ activeWorkspaceId: safeText(String(c.activeWorkspaceId ?? "")),
493
+ projectName: safeText(String(c.projectName ?? "")),
494
+ projectId: safeText(String(c.projectId ?? "")),
495
+ sessionGoal: safeText(String(c.sessionGoal ?? "")),
496
+ sessionId: safeText(String(c.sessionId ?? "")),
497
+ podUrl: safeText(String(shared?.podUrl || c.podUrl || "")),
498
+ };
307
499
  // ── Claude Code fields ───────────────────────────────────────────────
308
500
  const modelName = (cc.model?.display_name ?? "").replace(/\s*\((?:1M|200K) context\)/, "").trim();
309
501
  const ctxPct = cc.context_window?.used_percentage;
@@ -315,14 +507,7 @@ function render() {
315
507
  const sdPct = rl?.seven_day?.used_percentage;
316
508
  const fhReset = rl?.five_hour?.resets_at;
317
509
  const vimMode = cc.vim?.mode;
318
- const cwd = (cc.cwd ?? "");
319
- let branch = "";
320
- if (cwd) {
321
- try {
322
- branch = execSync(`git -C ${JSON.stringify(cwd)} --no-optional-locks symbolic-ref --short HEAD`, { encoding: "utf-8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"] }).trim();
323
- }
324
- catch { /* not a git repo */ }
325
- }
510
+ const branch = cwd ? gitBranchOf(cwd) : "";
326
511
  // ── Session stats (local file) ───────────────────────────────────────
327
512
  let callCount = 0;
328
513
  let sessionCount = 0;
@@ -401,22 +586,7 @@ function render() {
401
586
  const sg = pod.sessionId ? osc8(`synap://open/session/${pod.sessionId}`, pod.sessionGoal) : pod.sessionGoal;
402
587
  l2.push(`${D}▸${X} ${Y}${sg}${X}`);
403
588
  }
404
- if (pod.proposalCount > 0) {
405
- // cmd/ctrl-click opens the Proposals review app via the /open bounce
406
- // (bare `proposals` keyword → synap://open/proposals → browser opens the app).
407
- const proposalsLink = pod.podUrl ? `${pod.podUrl}/open/proposals` : "";
408
- const label = `${pod.proposalCount} proposals`;
409
- l2.push(`${D}↳${X} ${Y}${proposalsLink ? osc8(proposalsLink, label) : label}${X}`);
410
- }
411
- else if (pod.proposalsFetched) {
412
- // Genuinely fetched and empty — distinct from "never fetched" so the line
413
- // never silently omits a signal a user might mistake for "not checked yet".
414
- l2.push(`${D}↳ 0 proposals${X}`);
415
- }
416
- else {
417
- // No successful refresh has completed yet (fresh cache / all refreshes failed).
418
- l2.push(`${D}↳ proposals —${X}`);
419
- }
589
+ l2.push(needsYouSegment(pod, Date.now()));
420
590
  console.log(l2.join(" · "));
421
591
  // ─═─ LINE 3 — cost · rate limits · session stats ─═─────────────────────
422
592
  const l3 = [];
@@ -444,8 +614,82 @@ function render() {
444
614
  }
445
615
  if (l3.length)
446
616
  console.log(l3.join(" "));
617
+ // ─═─ LINE 4 — now: working / needs you / waiting on … / stopped ─═──────
618
+ // The bridge's status file first (what the POD says this conversation waits
619
+ // on); the transcript index when the file is absent, unbound, stale or
620
+ // unreadable — and an unreadable one is marked, never passed off as absent.
621
+ const now = Date.now();
622
+ const registry = claudeSessionId ? readRegistry(claudeSessionId) : null;
623
+ const bridge = claudeSessionId ? readBridgeStatus(claudeSessionId, { now, startedAt: registry?.startedAt }) : null;
624
+ if (bridge?.status === "fresh") {
625
+ console.log(buildBridgeLine({ now, file: bridge.file, writtenAt: bridge.writtenAt, registry, width: widthOf(cc) }));
626
+ return;
627
+ }
628
+ const line = buildNowLine({
629
+ now,
630
+ registry,
631
+ open: index ? Object.values(index.open) : [],
632
+ waits: claudeSessionId ? readWaits(claudeSessionId) : [],
633
+ width: widthOf(cc),
634
+ lastActivityAt: index?.lastEventAt || undefined,
635
+ alive: pidAlive,
636
+ });
637
+ console.log(bridge?.status === "failed" ? `${line} ${D}· ⚑ bridge status ${bridge.why}${X}` : line);
638
+ }
639
+ // ── Needs you (line 2) ─────────────────────────────────────────────────
640
+ /**
641
+ * Where "N need you" opens: the desktop's Inbox, the whole pod — the page
642
+ * the browser opens for needs-you (its Home door, and the landing of every
643
+ * older needs-you link: browser `navigation/resolveDeepLink.test.ts`), whose
644
+ * rows are the same rule the count is (`GET /needs-you`). Named directly:
645
+ * the pod's `/open/proposals` bounce reached it only through two retired
646
+ * aliases (proposals → governance → inbox), and its "View in browser"
647
+ * fallback is pod admin's review queue, a subset of the count.
648
+ */
649
+ export const NEEDS_YOU_LINK = "synap://open/app/inbox?scope=pod";
650
+ /**
651
+ * Line 2's needs-you segment. A count is drawn as fresh only when the latest
652
+ * attempt read it and it is younger than the pod dot's own staleness bound;
653
+ * otherwise it is dimmed and says how old it is (`⚑ 12m old`), or that its
654
+ * age is unknown (`⚑ not read`). Never measured is `need you —`, never 0.
655
+ */
656
+ export function needsYouSegment(pod, now) {
657
+ if (!pod.needsYouFetched)
658
+ return `${D}↳ need you —${X}`; // no read has ever succeeded
659
+ const label = `${pod.needsYouCount}${pod.needsYouTruncated ? "+" : ""} need you`;
660
+ // A cache from an older CLI has no measurement time: its `ts` is the claim it made.
661
+ const at = pod.needsYouAt ?? pod.ts;
662
+ const fresh = pod.ok && pod.needsYouOk !== false && at > 0 && now - at <= REFRESH_TTL_MS * 3;
663
+ if (!fresh) {
664
+ const old = at > 0 ? `⚑ ${age(now - at)} old` : "⚑ not read";
665
+ return `${D}↳ ${pod.needsYouCount > 0 ? osc8(NEEDS_YOU_LINK, label) : label} · ${old}${X}`;
666
+ }
667
+ // Genuinely fetched and empty — distinct from "never fetched".
668
+ if (pod.needsYouCount === 0)
669
+ return `${D}↳ 0 need you${X}`;
670
+ return `${D}↳${X} ${Y}${osc8(NEEDS_YOU_LINK, label)}${X}`;
447
671
  }
448
672
  // ── Entry ──────────────────────────────────────────────────────────────
673
+ /**
674
+ * The checked-out branch of `cwd`, or "" when it is not a git repo.
675
+ *
676
+ * `cwd` comes from Claude Code's statusline JSON: any directory the person
677
+ * opened, cloned repos included. It goes to git as ONE argv word, no shell:
678
+ * a `JSON.stringify`-quoted path in a shell string still ran `$(…)` and
679
+ * backticks in a directory name, on every statusline refresh.
680
+ */
681
+ export function gitBranchOf(cwd) {
682
+ try {
683
+ return execFileSync("git", ["-C", cwd, "--no-optional-locks", "symbolic-ref", "--short", "HEAD"], {
684
+ encoding: "utf-8",
685
+ timeout: 1000,
686
+ stdio: ["ignore", "pipe", "ignore"],
687
+ }).trim();
688
+ }
689
+ catch {
690
+ return ""; // not a git repo (or git missing): the segment is left out
691
+ }
692
+ }
449
693
  export async function statusline(opts) {
450
694
  if (opts?.refresh) {
451
695
  await refresh();