@synap-core/cli 1.13.0 → 1.13.1-next.20261102.ec3d166
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/README.md +13 -3
- package/dist/build-info.json +2 -3
- package/dist/commands/agents.js +12 -2
- package/dist/commands/agents.js.map +1 -1
- package/dist/commands/capability.d.ts +86 -0
- package/dist/commands/capability.js +117 -30
- package/dist/commands/capability.js.map +1 -1
- package/dist/commands/data-extra.d.ts +13 -0
- package/dist/commands/data-extra.js +23 -2
- package/dist/commands/data-extra.js.map +1 -1
- package/dist/commands/doctor.d.ts +18 -0
- package/dist/commands/doctor.js +28 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/init.js +4 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/keys-request.d.ts +48 -0
- package/dist/commands/keys-request.js +236 -0
- package/dist/commands/keys-request.js.map +1 -0
- package/dist/commands/keys-request.test.d.ts +1 -0
- package/dist/commands/keys-request.test.js +176 -0
- package/dist/commands/keys-request.test.js.map +1 -0
- package/dist/commands/knowledge.d.ts +6 -0
- package/dist/commands/knowledge.js +9 -1
- package/dist/commands/knowledge.js.map +1 -1
- package/dist/commands/market.d.ts +61 -4
- package/dist/commands/market.js +106 -48
- package/dist/commands/market.js.map +1 -1
- package/dist/commands/mcp.d.ts +1 -1
- package/dist/commands/mcp.js +14 -9
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/pods.js +6 -2
- package/dist/commands/pods.js.map +1 -1
- package/dist/commands/proposals-actions.js +7 -1
- package/dist/commands/proposals-actions.js.map +1 -1
- package/dist/commands/providers.d.ts +22 -6
- package/dist/commands/providers.js +39 -32
- package/dist/commands/providers.js.map +1 -1
- package/dist/commands/raycast.d.ts +15 -0
- package/dist/commands/raycast.js +36 -10
- package/dist/commands/raycast.js.map +1 -1
- package/dist/commands/status.js +12 -6
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/statusline.backoff.test.js +13 -3
- package/dist/commands/statusline.backoff.test.js.map +1 -1
- package/dist/commands/statusline.d.ts +104 -9
- package/dist/commands/statusline.js +392 -148
- package/dist/commands/statusline.js.map +1 -1
- package/dist/commands/statusline.line2.test.d.ts +15 -0
- package/dist/commands/statusline.line2.test.js +226 -0
- package/dist/commands/statusline.line2.test.js.map +1 -0
- package/dist/commands/statusline.needs-you.test.d.ts +1 -0
- package/dist/commands/statusline.needs-you.test.js +17 -0
- package/dist/commands/statusline.needs-you.test.js.map +1 -0
- package/dist/commands/statusline.now.test.d.ts +10 -0
- package/dist/commands/statusline.now.test.js +250 -0
- package/dist/commands/statusline.now.test.js.map +1 -0
- package/dist/commands/tracks.d.ts +1 -0
- package/dist/commands/tracks.js +4 -0
- package/dist/commands/tracks.js.map +1 -1
- package/dist/commands/upload.js +12 -0
- package/dist/commands/upload.js.map +1 -1
- package/dist/commands/wait.d.ts +124 -0
- package/dist/commands/wait.js +384 -0
- package/dist/commands/wait.js.map +1 -0
- package/dist/commands/wait.test.d.ts +8 -0
- package/dist/commands/wait.test.js +308 -0
- package/dist/commands/wait.test.js.map +1 -0
- package/dist/commands/worker-github-app.d.ts +63 -0
- package/dist/commands/worker-github-app.js +161 -0
- package/dist/commands/worker-github-app.js.map +1 -0
- package/dist/commands/worker-github-app.test.d.ts +1 -0
- package/dist/commands/worker-github-app.test.js +102 -0
- package/dist/commands/worker-github-app.test.js.map +1 -0
- package/dist/commands/worker-github-broker.d.ts +39 -0
- package/dist/commands/worker-github-broker.js +106 -0
- package/dist/commands/worker-github-broker.js.map +1 -0
- package/dist/commands/worker-github-broker.test.d.ts +1 -0
- package/dist/commands/worker-github-broker.test.js +90 -0
- package/dist/commands/worker-github-broker.test.js.map +1 -0
- package/dist/commands/worker-runs.test.d.ts +1 -0
- package/dist/commands/worker-runs.test.js +150 -0
- package/dist/commands/worker-runs.test.js.map +1 -0
- package/dist/commands/worker-worktrees.d.ts +34 -0
- package/dist/commands/worker-worktrees.js +87 -0
- package/dist/commands/worker-worktrees.js.map +1 -0
- package/dist/commands/worker.d.ts +192 -5
- package/dist/commands/worker.js +467 -45
- package/dist/commands/worker.js.map +1 -1
- package/dist/commands/worker.test.js +269 -3
- package/dist/commands/worker.test.js.map +1 -1
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/lib/agents-config.d.ts +9 -0
- package/dist/lib/agents-config.js +20 -0
- package/dist/lib/agents-config.js.map +1 -1
- package/dist/lib/atomic-file.d.ts +20 -1
- package/dist/lib/atomic-file.js +100 -2
- package/dist/lib/atomic-file.js.map +1 -1
- package/dist/lib/auth.d.ts +25 -0
- package/dist/lib/auth.js +49 -9
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/box/job-server.js +4 -0
- package/dist/lib/box/job-server.js.map +1 -1
- package/dist/lib/bridge-invocation.test.d.ts +1 -0
- package/dist/lib/bridge-invocation.test.js +75 -0
- package/dist/lib/bridge-invocation.test.js.map +1 -0
- package/dist/lib/bridge-status.d.ts +144 -0
- package/dist/lib/bridge-status.js +227 -0
- package/dist/lib/bridge-status.js.map +1 -0
- package/dist/lib/bridge-status.test.d.ts +6 -0
- package/dist/lib/bridge-status.test.js +197 -0
- package/dist/lib/bridge-status.test.js.map +1 -0
- package/dist/lib/client-header.test.js +19 -1
- package/dist/lib/client-header.test.js.map +1 -1
- package/dist/lib/conversation-headers.d.ts +67 -2
- package/dist/lib/conversation-headers.js +122 -6
- package/dist/lib/conversation-headers.js.map +1 -1
- package/dist/lib/conversation-headers.test.d.ts +1 -0
- package/dist/lib/conversation-headers.test.js +78 -0
- package/dist/lib/conversation-headers.test.js.map +1 -0
- package/dist/lib/credential-class.d.ts +12 -1
- package/dist/lib/credential-class.js +20 -0
- package/dist/lib/credential-class.js.map +1 -1
- package/dist/lib/device-connect.d.ts +45 -0
- package/dist/lib/device-connect.js +60 -0
- package/dist/lib/device-connect.js.map +1 -1
- package/dist/lib/goal-words.js +6 -3
- package/dist/lib/goal-words.js.map +1 -1
- package/dist/lib/hub-client.d.ts +59 -1
- package/dist/lib/hub-client.js +130 -36
- package/dist/lib/hub-client.js.map +1 -1
- package/dist/lib/key-locations.d.ts +3 -3
- package/dist/lib/key-locations.js +3 -39
- package/dist/lib/key-locations.js.map +1 -1
- package/dist/lib/machine-instance.d.ts +7 -5
- package/dist/lib/machine-instance.js +7 -5
- package/dist/lib/machine-instance.js.map +1 -1
- package/dist/lib/mcp-bridge.d.ts +43 -0
- package/dist/lib/mcp-bridge.js +199 -11
- package/dist/lib/mcp-bridge.js.map +1 -1
- package/dist/lib/mcp-bridge.test.js +194 -2
- package/dist/lib/mcp-bridge.test.js.map +1 -1
- package/dist/lib/mcp-call.d.ts +5 -1
- package/dist/lib/mcp-call.js +7 -6
- package/dist/lib/mcp-call.js.map +1 -1
- package/dist/lib/mcp-connector-url.test.d.ts +1 -0
- package/dist/lib/mcp-connector-url.test.js +13 -0
- package/dist/lib/mcp-connector-url.test.js.map +1 -0
- package/dist/lib/mcp-snippets.d.ts +16 -0
- package/dist/lib/mcp-snippets.js +20 -2
- package/dist/lib/mcp-snippets.js.map +1 -1
- package/dist/lib/now-line.d.ts +66 -0
- package/dist/lib/now-line.js +193 -0
- package/dist/lib/now-line.js.map +1 -0
- package/dist/lib/now-line.test.d.ts +5 -0
- package/dist/lib/now-line.test.js +177 -0
- package/dist/lib/now-line.test.js.map +1 -0
- package/dist/lib/package-updates.d.ts +66 -0
- package/dist/lib/package-updates.js +76 -0
- package/dist/lib/package-updates.js.map +1 -0
- package/dist/lib/pod.d.ts +0 -4
- package/dist/lib/pod.js +0 -7
- package/dist/lib/pod.js.map +1 -1
- package/dist/lib/register-claude-code-mcp.test.js +19 -0
- package/dist/lib/register-claude-code-mcp.test.js.map +1 -1
- package/dist/lib/safe-open.d.ts +5 -3
- package/dist/lib/safe-open.js +9 -8
- package/dist/lib/safe-open.js.map +1 -1
- package/dist/lib/self-host.d.ts +17 -0
- package/dist/lib/self-host.js +24 -0
- package/dist/lib/self-host.js.map +1 -0
- package/dist/lib/targets.d.ts +31 -0
- package/dist/lib/targets.js +100 -104
- package/dist/lib/targets.js.map +1 -1
- package/dist/lib/term.d.ts +24 -0
- package/dist/lib/term.js +52 -0
- package/dist/lib/term.js.map +1 -0
- package/dist/lib/term.test.d.ts +1 -0
- package/dist/lib/term.test.js +29 -0
- package/dist/lib/term.test.js.map +1 -0
- package/dist/lib/transcript-index.d.ts +94 -0
- package/dist/lib/transcript-index.js +321 -0
- package/dist/lib/transcript-index.js.map +1 -0
- package/dist/lib/transcript-index.test.d.ts +10 -0
- package/dist/lib/transcript-index.test.js +200 -0
- package/dist/lib/transcript-index.test.js.map +1 -0
- package/dist/lib/wait-help.d.ts +11 -0
- package/dist/lib/wait-help.js +25 -0
- package/dist/lib/wait-help.js.map +1 -0
- package/dist/lib/wait-state.d.ts +57 -0
- package/dist/lib/wait-state.js +114 -0
- package/dist/lib/wait-state.js.map +1 -0
- package/package.json +2 -2
- package/skills/synap/SKILL.md +71 -38
- package/skills/synap/code-skills.md +15 -1
- package/skills/synap/concepts.md +36 -26
- package/skills/synap/document-embeds.md +15 -1
- package/skills/synap/focus-sessions.md +4 -4
- package/skills/synap/from-intent.md +2 -2
- package/skills/synap/governance-rules.md +2 -0
- package/skills/synap/reflexes.md +4 -4
- package/skills/synap/work-model.md +1 -1
- package/skills/synap/writing-titles.md +7 -0
- package/skills/synap-app/SKILL.md +2 -2
- package/skills/synap-app/overview.md +2 -2
- package/skills/synap-market/SKILL.md +1 -1
- package/skills/synap-market/governance-and-catalog.md +1 -1
- package/skills/synap-schema/SKILL.md +4 -1
- package/skills/synap-schema/intent-vocabulary-ssot.md +4 -1
- package/dist/commands/template.d.ts +0 -42
- package/dist/commands/template.js +0 -191
- package/dist/commands/template.js.map +0 -1
- package/dist/lib/browser-auth.d.ts +0 -35
- package/dist/lib/browser-auth.js +0 -170
- package/dist/lib/browser-auth.js.map +0 -1
- package/dist/lib/openclaw.d.ts +0 -32
- package/dist/lib/openclaw.js +0 -165
- package/dist/lib/openclaw.js.map +0 -1
- package/dist/lib/seed.d.ts +0 -13
- package/dist/lib/seed.js +0 -135
- package/dist/lib/seed.js.map +0 -1
|
@@ -1,18 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* synap statusline — full
|
|
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
|
|
8
|
-
* exit. Always instant. If
|
|
9
|
-
* detached refresh (fire-and-forget) so
|
|
10
|
-
* fresh — this render never blocks on
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* On error/429, the last good cache is preserved
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
// ──
|
|
39
|
-
|
|
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,
|
|
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
|
|
116
|
+
function readJson(file) {
|
|
93
117
|
try {
|
|
94
|
-
return JSON.parse(fs.readFileSync(
|
|
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(
|
|
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
|
|
110
|
-
if (lockIsFresh())
|
|
111
|
-
return; //
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
242
|
-
//
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
const
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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.
|
|
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 +
|
|
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
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
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
|
|
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
|
-
|
|
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();
|