@tryinget/pi-activity-strip 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -7
- package/bin/pi-activity-strip-claude-hook.mjs +71 -0
- package/bin/pi-activity-strip.mjs +98 -64
- package/extensions/activity-strip.js +25 -21
- package/native/bin/linux-x64-gnu/artifact.json +2 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +20 -20
- package/native/panel/src/card_view.rs +396 -9
- package/native/panel/src/main.rs +64 -1
- package/native/panel/src/protocol.rs +142 -0
- package/native/panel/src/runtime.rs +50 -9
- package/native/panel/src/style.css +147 -60
- package/package.json +4 -4
- package/src/client/runtime-lock.mjs +140 -0
- package/src/common/agent-identity.mjs +163 -0
- package/src/common/ak-tasks.mjs +354 -0
- package/src/common/claude-events.mjs +190 -0
- package/src/common/claude-hook-config.mjs +56 -0
- package/src/common/claude-transcript.mjs +163 -0
- package/src/common/codex-transcript.mjs +153 -0
- package/src/common/compatibility.mjs +13 -0
- package/src/common/constants.mjs +2 -0
- package/src/common/contracts.d.ts +35 -0
- package/src/common/contracts.ts +67 -1
- package/src/common/ghostty-present.mjs +100 -0
- package/src/common/ghostty-theme.mjs +149 -0
- package/src/common/niri-focus.mjs +179 -111
- package/src/common/session-cards.mjs +5 -2
- package/src/common/status-report.mjs +51 -0
- package/src/common/surface-bindings.mjs +234 -0
- package/src/common/terminal-identity.mjs +75 -12
- package/src/common/window-placement.mjs +59 -0
- package/src/common/workspace-view.mjs +161 -0
- package/src/native/agent-discovery.mjs +294 -0
- package/src/native/ak-runtime.mjs +164 -0
- package/src/native/codex-discovery.mjs +128 -0
- package/src/native/ghostty-tab-inventory.py +99 -0
- package/src/native/height-repair.mjs +154 -0
- package/src/native/main.mjs +122 -64
- package/src/native/panel-binary.mjs +56 -0
- package/src/native/panel-projection.mjs +70 -5
- package/src/native/placement.mjs +225 -0
- package/src/native/tab-inventory.mjs +188 -0
- package/src/native/theme-runtime.mjs +72 -0
- package/src/native/theme-source.mjs +197 -0
- package/src/native/workspace-events.mjs +22 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "maps Claude Code hook payloads to activity-strip state and stores one record per session"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing Claude Code hook events, their state mapping, or the event record on disk"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { ACTIVITY_STRIP_SOCKET_DIR } from "./constants.mjs";
|
|
10
|
+
|
|
11
|
+
export const CLAUDE_EVENT_SCHEMA = "pi-activity-strip-claude-event.v1";
|
|
12
|
+
export const CLAUDE_EVENT_DIR = path.join(ACTIVITY_STRIP_SOCKET_DIR, "claude-events");
|
|
13
|
+
/** Hook events older than this are ignored; the transcript remains the fallback. */
|
|
14
|
+
export const CLAUDE_EVENT_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
15
|
+
/**
|
|
16
|
+
* A session that dies without firing `SessionEnd` leaves its record behind. Such a record can never
|
|
17
|
+
* produce a card, because a card needs a live process, but the files would accumulate forever. They
|
|
18
|
+
* are retired once no live session claims them and they have stopped being recent, so a session
|
|
19
|
+
* that publishes before the scan first sees it is never swept away mid-startup.
|
|
20
|
+
*/
|
|
21
|
+
export const CLAUDE_EVENT_ORPHAN_GRACE_MS = 10 * 60_000;
|
|
22
|
+
const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
23
|
+
const DETAIL_LIMIT = 100;
|
|
24
|
+
|
|
25
|
+
/** @typedef {"idle" | "thinking" | "tool" | "waiting" | "error"} ClaudeEventState */
|
|
26
|
+
|
|
27
|
+
/** @param {unknown} value */
|
|
28
|
+
function preview(value) {
|
|
29
|
+
const text = String(value ?? "")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
return text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT - 1)}…` : text;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** @param {unknown} sessionId */
|
|
36
|
+
export function claudeEventPath(sessionId, directory = CLAUDE_EVENT_DIR) {
|
|
37
|
+
const normalized = String(sessionId ?? "")
|
|
38
|
+
.trim()
|
|
39
|
+
.toLowerCase();
|
|
40
|
+
return SESSION_ID.test(normalized) ? path.join(directory, `${normalized}.json`) : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Name what a tool call is acting on, preferring fields that read well on a card.
|
|
45
|
+
* @param {Record<string, unknown>} toolInput
|
|
46
|
+
*/
|
|
47
|
+
export function describeHookToolTarget(toolInput) {
|
|
48
|
+
for (const key of ["description", "command", "file_path", "path", "pattern", "query", "url"]) {
|
|
49
|
+
const value = /** @type {any} */ (toolInput)?.[key];
|
|
50
|
+
if (typeof value === "string" && value.trim()) return preview(value);
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Translate one hook payload into the record the ribbon stores. `null` means the event carries no
|
|
57
|
+
* usable identity and must be dropped rather than guessed.
|
|
58
|
+
* @param {Record<string, unknown>} payload
|
|
59
|
+
* @param {{now?: number; env?: NodeJS.ProcessEnv}} [options]
|
|
60
|
+
*/
|
|
61
|
+
export function claudeEventRecord(payload, { now = Date.now(), env = process.env } = {}) {
|
|
62
|
+
const sessionId = String(payload?.session_id ?? "")
|
|
63
|
+
.trim()
|
|
64
|
+
.toLowerCase();
|
|
65
|
+
if (!SESSION_ID.test(sessionId)) return null;
|
|
66
|
+
const event = String(payload?.hook_event_name ?? "").trim();
|
|
67
|
+
if (!event) return null;
|
|
68
|
+
|
|
69
|
+
const toolName = String(payload?.tool_name ?? "").trim();
|
|
70
|
+
const toolInput = /** @type {Record<string, unknown>} */ (payload?.tool_input ?? {});
|
|
71
|
+
const notification = String(payload?.notification_type ?? "").trim();
|
|
72
|
+
|
|
73
|
+
/** @type {ClaudeEventState} */
|
|
74
|
+
let state = "thinking";
|
|
75
|
+
let detail = "";
|
|
76
|
+
if (event === "PreToolUse") {
|
|
77
|
+
state = "tool";
|
|
78
|
+
detail = describeHookToolTarget(toolInput);
|
|
79
|
+
} else if (event === "PostToolUseFailure") {
|
|
80
|
+
state = "error";
|
|
81
|
+
detail = `${toolName || "tool"} failed`;
|
|
82
|
+
} else if (event === "PostToolUse") {
|
|
83
|
+
state = "thinking";
|
|
84
|
+
} else if (event === "Notification") {
|
|
85
|
+
state = "waiting";
|
|
86
|
+
detail =
|
|
87
|
+
notification === "permission_prompt"
|
|
88
|
+
? "waiting for approval"
|
|
89
|
+
: notification === "idle_prompt" || notification === "agent_needs_input"
|
|
90
|
+
? "waiting for input"
|
|
91
|
+
: preview(notification) || "waiting";
|
|
92
|
+
} else if (event === "Stop" || event === "SubagentStop") {
|
|
93
|
+
state = "idle";
|
|
94
|
+
detail = preview(payload?.last_assistant_message);
|
|
95
|
+
} else if (event === "UserPromptSubmit") {
|
|
96
|
+
state = "thinking";
|
|
97
|
+
} else if (event === "SessionStart") {
|
|
98
|
+
state = "idle";
|
|
99
|
+
} else if (event === "SessionEnd") {
|
|
100
|
+
state = "idle";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
schema: CLAUDE_EVENT_SCHEMA,
|
|
105
|
+
sessionId,
|
|
106
|
+
event,
|
|
107
|
+
state,
|
|
108
|
+
toolName: state === "tool" || state === "error" ? toolName : "",
|
|
109
|
+
toolTarget: state === "tool" ? detail : "",
|
|
110
|
+
detail,
|
|
111
|
+
notificationType: notification,
|
|
112
|
+
cwd: String(payload?.cwd ?? ""),
|
|
113
|
+
transcriptPath: String(payload?.transcript_path ?? ""),
|
|
114
|
+
surfaceId: String(env.GHOSTTY_SURFACE_ID ?? ""),
|
|
115
|
+
processId: Number(env.PI_ACTIVITY_STRIP_HOOK_PID ?? 0) || 0,
|
|
116
|
+
at: now,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `SessionEnd` retires a session, so its record is removed rather than left behind.
|
|
122
|
+
* @param {{event?: unknown} | null} record
|
|
123
|
+
*/
|
|
124
|
+
export function isSessionEndEvent(record) {
|
|
125
|
+
return record?.event === "SessionEnd";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fold a stored hook record into transcript telemetry. The newer of the two wins, so a live hook
|
|
130
|
+
* event overrides a stale transcript read and a transcript that moved after the last hook still
|
|
131
|
+
* shows current work.
|
|
132
|
+
* @param {import("./claude-transcript.mjs").ClaudeTelemetry} telemetry
|
|
133
|
+
* @param {ReturnType<typeof claudeEventRecord>} record
|
|
134
|
+
* @param {number} [now]
|
|
135
|
+
*/
|
|
136
|
+
export function mergeClaudeEvent(telemetry, record, now = Date.now()) {
|
|
137
|
+
if (!record || record.schema !== CLAUDE_EVENT_SCHEMA) return telemetry;
|
|
138
|
+
const at = Number(record.at ?? 0);
|
|
139
|
+
if (!Number.isFinite(at) || at <= 0 || now - at > CLAUDE_EVENT_MAX_AGE_MS) return telemetry;
|
|
140
|
+
if (at <= Number(telemetry?.lastEventAt ?? 0)) return telemetry;
|
|
141
|
+
return {
|
|
142
|
+
...telemetry,
|
|
143
|
+
state: /** @type {any} */ (record.state),
|
|
144
|
+
toolName: String(record.toolName ?? ""),
|
|
145
|
+
toolTarget: String(record.toolTarget ?? ""),
|
|
146
|
+
assistantPreview: record.detail || telemetry.assistantPreview,
|
|
147
|
+
lastEventAt: at,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove hook records that no live session claims. Returns the retired session ids.
|
|
153
|
+
* @param {Iterable<string>} activeSessionIds
|
|
154
|
+
* @param {{fs?: Pick<typeof fs, "readdirSync" | "readFileSync" | "unlinkSync">; directory?: string; now?: number}} [options]
|
|
155
|
+
*/
|
|
156
|
+
export function pruneClaudeEventRecords(
|
|
157
|
+
activeSessionIds,
|
|
158
|
+
{ fs: fsImpl = fs, directory = CLAUDE_EVENT_DIR, now = Date.now() } = {},
|
|
159
|
+
) {
|
|
160
|
+
const active = new Set(activeSessionIds ?? []);
|
|
161
|
+
/** @type {string[]} */
|
|
162
|
+
const retired = [];
|
|
163
|
+
let entries;
|
|
164
|
+
try {
|
|
165
|
+
entries = fsImpl.readdirSync(directory);
|
|
166
|
+
} catch {
|
|
167
|
+
return retired;
|
|
168
|
+
}
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!entry.endsWith(".json")) continue;
|
|
171
|
+
const sessionId = entry.slice(0, -".json".length);
|
|
172
|
+
if (active.has(sessionId)) continue;
|
|
173
|
+
let publishedAt = 0;
|
|
174
|
+
try {
|
|
175
|
+
publishedAt = Number(
|
|
176
|
+
JSON.parse(String(fsImpl.readFileSync(path.join(directory, entry), "utf8"))).at ?? 0,
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
publishedAt = 0;
|
|
180
|
+
}
|
|
181
|
+
if (publishedAt > 0 && now - publishedAt < CLAUDE_EVENT_ORPHAN_GRACE_MS) continue;
|
|
182
|
+
try {
|
|
183
|
+
fsImpl.unlinkSync(path.join(directory, entry));
|
|
184
|
+
retired.push(sessionId);
|
|
185
|
+
} catch {
|
|
186
|
+
// Another writer removed it first.
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return retired;
|
|
190
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "builds the Claude Code settings fragment that lets sessions publish live ribbon state"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing which Claude Code hook events the ribbon subscribes to"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Only low-frequency events are hooked. Tool, thinking and idle state already come from the
|
|
9
|
+
* transcript at no cost, so nothing runs per tool call; hooks add exactly what the transcript
|
|
10
|
+
* cannot express, which is that a session is blocked waiting for the operator.
|
|
11
|
+
*/
|
|
12
|
+
export const CLAUDE_HOOK_EVENTS = Object.freeze([
|
|
13
|
+
"SessionStart",
|
|
14
|
+
"UserPromptSubmit",
|
|
15
|
+
"Notification",
|
|
16
|
+
"Stop",
|
|
17
|
+
"SessionEnd",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} hookCommand absolute command Claude Code runs for each event
|
|
22
|
+
* @returns {{hooks: Record<string, Array<{matcher: string; hooks: Array<{type: "command"; command: string}>}>>}}
|
|
23
|
+
*/
|
|
24
|
+
export function claudeHookSettings(hookCommand) {
|
|
25
|
+
const command = String(hookCommand ?? "").trim();
|
|
26
|
+
if (!command) throw new Error("A hook command is required.");
|
|
27
|
+
/** @type {Record<string, Array<{matcher: string; hooks: Array<{type: "command"; command: string}>}>>} */
|
|
28
|
+
const hooks = {};
|
|
29
|
+
for (const event of CLAUDE_HOOK_EVENTS) {
|
|
30
|
+
hooks[event] = [{ matcher: "", hooks: [{ type: "command", command }] }];
|
|
31
|
+
}
|
|
32
|
+
return { hooks };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Merge the ribbon's hooks into existing settings without disturbing unrelated configuration or
|
|
37
|
+
* duplicating an entry that already runs this exact command.
|
|
38
|
+
* @param {Record<string, any>} settings
|
|
39
|
+
* @param {string} hookCommand
|
|
40
|
+
*/
|
|
41
|
+
export function mergeClaudeHookSettings(settings, hookCommand) {
|
|
42
|
+
const desired = claudeHookSettings(hookCommand);
|
|
43
|
+
const merged = { ...(settings ?? {}) };
|
|
44
|
+
const existingHooks = { ...(merged.hooks ?? {}) };
|
|
45
|
+
for (const [event, entries] of Object.entries(desired.hooks)) {
|
|
46
|
+
const current = Array.isArray(existingHooks[event]) ? [...existingHooks[event]] : [];
|
|
47
|
+
const alreadyPresent = current.some((entry) =>
|
|
48
|
+
(entry?.hooks ?? []).some(
|
|
49
|
+
/** @param {any} hook */ (hook) => String(hook?.command ?? "") === hookCommand,
|
|
50
|
+
),
|
|
51
|
+
);
|
|
52
|
+
existingHooks[event] = alreadyPresent ? current : [...current, ...entries];
|
|
53
|
+
}
|
|
54
|
+
merged.hooks = existingHooks;
|
|
55
|
+
return merged;
|
|
56
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "derives live Claude Code session state from the tail of its transcript"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing Claude Code card telemetry or the transcript records it depends on"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Claude Code appends newline-delimited records to a per-session transcript. The format is internal
|
|
9
|
+
* to Claude Code and carries no stability guarantee, so every field is optional here and a record
|
|
10
|
+
* that does not parse is skipped. When nothing usable is found the caller keeps the process-only
|
|
11
|
+
* card rather than inventing activity.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const PREVIEW_LIMIT = 100;
|
|
15
|
+
const DETAIL_LIMIT = 100;
|
|
16
|
+
|
|
17
|
+
/** @typedef {"idle" | "thinking" | "tool"} ClaudeState */
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{
|
|
20
|
+
* title: string; lastPrompt: string; assistantPreview: string; state: ClaudeState;
|
|
21
|
+
* toolName: string; toolTarget: string; turnIndex: number; lastEventAt: number;
|
|
22
|
+
* mode: string; permissionMode: string; sessionId: string; cwd: string; gitBranch: string;
|
|
23
|
+
* }} ClaudeTelemetry
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** @param {unknown} value @param {number} limit */
|
|
27
|
+
function preview(value, limit) {
|
|
28
|
+
const text = String(value ?? "")
|
|
29
|
+
.replace(/\s+/g, " ")
|
|
30
|
+
.trim();
|
|
31
|
+
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** @param {unknown} value */
|
|
35
|
+
function timestampMs(value) {
|
|
36
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
37
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** @param {any} record */
|
|
41
|
+
function contentBlocks(record) {
|
|
42
|
+
const content = record?.message?.content;
|
|
43
|
+
return Array.isArray(content) ? content : [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Name the thing a tool call is acting on, preferring the fields that read well on a card.
|
|
48
|
+
* @param {any} block
|
|
49
|
+
*/
|
|
50
|
+
export function describeToolTarget(block) {
|
|
51
|
+
const input = block?.input ?? {};
|
|
52
|
+
for (const key of ["description", "command", "file_path", "path", "pattern", "query", "url"]) {
|
|
53
|
+
const value = input?.[key];
|
|
54
|
+
if (typeof value === "string" && value.trim()) return preview(value, DETAIL_LIMIT);
|
|
55
|
+
}
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @returns {ClaudeTelemetry} */
|
|
60
|
+
function emptyTelemetry() {
|
|
61
|
+
return {
|
|
62
|
+
title: "",
|
|
63
|
+
lastPrompt: "",
|
|
64
|
+
assistantPreview: "",
|
|
65
|
+
state: "idle",
|
|
66
|
+
toolName: "",
|
|
67
|
+
toolTarget: "",
|
|
68
|
+
turnIndex: 0,
|
|
69
|
+
lastEventAt: 0,
|
|
70
|
+
mode: "",
|
|
71
|
+
permissionMode: "",
|
|
72
|
+
sessionId: "",
|
|
73
|
+
cwd: "",
|
|
74
|
+
gitBranch: "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the tail of a transcript. The first line is usually a fragment and is skipped by the parse
|
|
80
|
+
* failure path. State comes from the newest record that carries a timestamp, because the latched
|
|
81
|
+
* records Claude Code rewrites after every turn have none and would otherwise mask real activity.
|
|
82
|
+
* @param {string} tail
|
|
83
|
+
* @returns {ClaudeTelemetry}
|
|
84
|
+
*/
|
|
85
|
+
export function parseClaudeTranscriptTail(tail) {
|
|
86
|
+
const telemetry = emptyTelemetry();
|
|
87
|
+
if (!tail) return telemetry;
|
|
88
|
+
|
|
89
|
+
/** @type {any[]} */
|
|
90
|
+
const records = [];
|
|
91
|
+
for (const line of String(tail).split("\n")) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed.startsWith("{")) continue;
|
|
94
|
+
try {
|
|
95
|
+
records.push(JSON.parse(trimmed));
|
|
96
|
+
} catch {
|
|
97
|
+
// A truncated first line or a partially written last line is not evidence.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (records.length === 0) return telemetry;
|
|
101
|
+
|
|
102
|
+
for (const record of records) {
|
|
103
|
+
switch (record?.type) {
|
|
104
|
+
case "ai-title":
|
|
105
|
+
if (typeof record.aiTitle === "string") telemetry.title = record.aiTitle;
|
|
106
|
+
break;
|
|
107
|
+
case "last-prompt":
|
|
108
|
+
telemetry.lastPrompt = preview(record.lastPrompt, PREVIEW_LIMIT);
|
|
109
|
+
break;
|
|
110
|
+
case "mode":
|
|
111
|
+
telemetry.mode = String(record.mode ?? "");
|
|
112
|
+
break;
|
|
113
|
+
case "permission-mode":
|
|
114
|
+
telemetry.permissionMode = String(record.permissionMode ?? "");
|
|
115
|
+
break;
|
|
116
|
+
case "system":
|
|
117
|
+
if (record.subtype === "turn_duration" && Number.isFinite(Number(record.messageCount))) {
|
|
118
|
+
telemetry.turnIndex = Number(record.messageCount);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
if (typeof record?.sessionId === "string") telemetry.sessionId = record.sessionId;
|
|
125
|
+
if (typeof record?.cwd === "string") telemetry.cwd = record.cwd;
|
|
126
|
+
if (typeof record?.gitBranch === "string") telemetry.gitBranch = record.gitBranch;
|
|
127
|
+
if (record?.type === "assistant") {
|
|
128
|
+
const text = contentBlocks(record)
|
|
129
|
+
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
|
130
|
+
.map((block) => block.text)
|
|
131
|
+
.join(" ");
|
|
132
|
+
if (text.trim()) telemetry.assistantPreview = preview(text, PREVIEW_LIMIT);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const timed = records.filter(
|
|
137
|
+
(record) => record?.type !== "attachment" && timestampMs(record?.timestamp) > 0,
|
|
138
|
+
);
|
|
139
|
+
const latest = timed.at(-1);
|
|
140
|
+
if (!latest) return telemetry;
|
|
141
|
+
telemetry.lastEventAt = timestampMs(latest.timestamp);
|
|
142
|
+
|
|
143
|
+
if (latest.type === "system" && latest.subtype === "turn_duration") {
|
|
144
|
+
telemetry.state = "idle";
|
|
145
|
+
return telemetry;
|
|
146
|
+
}
|
|
147
|
+
if (latest.type === "assistant") {
|
|
148
|
+
const toolBlock = contentBlocks(latest).find((block) => block?.type === "tool_use");
|
|
149
|
+
if (toolBlock) {
|
|
150
|
+
telemetry.state = "tool";
|
|
151
|
+
telemetry.toolName = String(toolBlock.name ?? "");
|
|
152
|
+
telemetry.toolTarget = describeToolTarget(toolBlock);
|
|
153
|
+
return telemetry;
|
|
154
|
+
}
|
|
155
|
+
telemetry.state = latest.message?.stop_reason === "end_turn" ? "idle" : "thinking";
|
|
156
|
+
return telemetry;
|
|
157
|
+
}
|
|
158
|
+
if (latest.type === "user") {
|
|
159
|
+
telemetry.state = "thinking";
|
|
160
|
+
return telemetry;
|
|
161
|
+
}
|
|
162
|
+
return telemetry;
|
|
163
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ---
|
|
2
|
+
// summary: "derives live Codex session state from the tail of its rollout file"
|
|
3
|
+
// read_when:
|
|
4
|
+
// - "changing Codex card telemetry or the rollout records it depends on"
|
|
5
|
+
// ---
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Codex appends newline-delimited records to a per-session rollout. Every record carries a
|
|
9
|
+
* top-level `timestamp`, a `type` of `session_meta`, `turn_context`, `event_msg` or
|
|
10
|
+
* `response_item`, and a `payload` whose own `type` names the event. The format belongs to Codex
|
|
11
|
+
* and carries no stability guarantee, so every field is optional here and an unparseable record is
|
|
12
|
+
* skipped. When nothing usable is found the caller keeps the process-only card.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const PREVIEW_LIMIT = 100;
|
|
16
|
+
const TOOL_ARGUMENT_KEYS = ["cmd", "command", "query", "path", "file_path", "pattern", "workdir"];
|
|
17
|
+
|
|
18
|
+
/** @typedef {import("./claude-transcript.mjs").ClaudeTelemetry} AgentTelemetry */
|
|
19
|
+
|
|
20
|
+
/** @param {unknown} value */
|
|
21
|
+
function preview(value) {
|
|
22
|
+
const text = String(value ?? "")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
return text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT - 1)}…` : text;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** @param {unknown} value */
|
|
29
|
+
function timestampMs(value) {
|
|
30
|
+
const parsed = Date.parse(String(value ?? ""));
|
|
31
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Name what a Codex tool call is acting on. Arguments arrive as a JSON string, and a call whose
|
|
36
|
+
* arguments do not parse still reports its tool name.
|
|
37
|
+
* @param {any} payload
|
|
38
|
+
*/
|
|
39
|
+
export function describeCodexToolTarget(payload) {
|
|
40
|
+
let args = payload?.arguments;
|
|
41
|
+
if (typeof args === "string") {
|
|
42
|
+
try {
|
|
43
|
+
args = JSON.parse(args);
|
|
44
|
+
} catch {
|
|
45
|
+
return preview(args);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!args || typeof args !== "object") return "";
|
|
49
|
+
for (const key of TOOL_ARGUMENT_KEYS) {
|
|
50
|
+
const value = args[key];
|
|
51
|
+
if (typeof value === "string" && value.trim()) return preview(value);
|
|
52
|
+
if (Array.isArray(value) && value.length > 0) return preview(value.join(" "));
|
|
53
|
+
}
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {any} payload */
|
|
58
|
+
function toolNameFor(payload) {
|
|
59
|
+
const type = String(payload?.type ?? "");
|
|
60
|
+
if (type === "function_call" || type === "custom_tool_call") {
|
|
61
|
+
return String(payload?.name ?? "") || "tool";
|
|
62
|
+
}
|
|
63
|
+
if (type === "local_shell_call") return "shell";
|
|
64
|
+
if (type === "web_search_call") return "web_search";
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** @returns {AgentTelemetry} */
|
|
69
|
+
function emptyTelemetry() {
|
|
70
|
+
return {
|
|
71
|
+
title: "",
|
|
72
|
+
lastPrompt: "",
|
|
73
|
+
assistantPreview: "",
|
|
74
|
+
state: "idle",
|
|
75
|
+
toolName: "",
|
|
76
|
+
toolTarget: "",
|
|
77
|
+
turnIndex: 0,
|
|
78
|
+
lastEventAt: 0,
|
|
79
|
+
mode: "",
|
|
80
|
+
permissionMode: "",
|
|
81
|
+
sessionId: "",
|
|
82
|
+
cwd: "",
|
|
83
|
+
gitBranch: "",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Read the tail of a rollout. The first line is usually a fragment and is skipped by the parse
|
|
89
|
+
* failure path. State comes from the newest record that carries a timestamp, so a tool call is
|
|
90
|
+
* reported as running only while it is still the newest record; its output lands after it.
|
|
91
|
+
* @param {string} tail
|
|
92
|
+
* @returns {AgentTelemetry}
|
|
93
|
+
*/
|
|
94
|
+
export function parseCodexRolloutTail(tail) {
|
|
95
|
+
const telemetry = emptyTelemetry();
|
|
96
|
+
if (!tail) return telemetry;
|
|
97
|
+
|
|
98
|
+
/** @type {any[]} */
|
|
99
|
+
const records = [];
|
|
100
|
+
for (const line of String(tail).split("\n")) {
|
|
101
|
+
const trimmed = line.trim();
|
|
102
|
+
if (!trimmed.startsWith("{")) continue;
|
|
103
|
+
try {
|
|
104
|
+
records.push(JSON.parse(trimmed));
|
|
105
|
+
} catch {
|
|
106
|
+
// A truncated first or last line is not evidence.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (records.length === 0) return telemetry;
|
|
110
|
+
|
|
111
|
+
for (const record of records) {
|
|
112
|
+
const payload = record?.payload;
|
|
113
|
+
const payloadType = String(payload?.type ?? "");
|
|
114
|
+
if (record?.type === "session_meta") {
|
|
115
|
+
telemetry.sessionId = String(payload?.id ?? telemetry.sessionId);
|
|
116
|
+
telemetry.cwd = String(payload?.cwd ?? telemetry.cwd);
|
|
117
|
+
const branch = payload?.git?.branch;
|
|
118
|
+
if (typeof branch === "string") telemetry.gitBranch = branch;
|
|
119
|
+
} else if (record?.type === "turn_context") {
|
|
120
|
+
telemetry.cwd = String(payload?.cwd ?? telemetry.cwd);
|
|
121
|
+
telemetry.mode = String(payload?.approval_policy ?? telemetry.mode);
|
|
122
|
+
const sandbox = payload?.sandbox_policy?.type;
|
|
123
|
+
if (typeof sandbox === "string") telemetry.permissionMode = sandbox;
|
|
124
|
+
} else if (payloadType === "user_message") {
|
|
125
|
+
telemetry.lastPrompt = preview(payload?.message);
|
|
126
|
+
} else if (payloadType === "agent_message") {
|
|
127
|
+
telemetry.assistantPreview = preview(payload?.message);
|
|
128
|
+
} else if (payloadType === "task_complete") {
|
|
129
|
+
telemetry.turnIndex += 1;
|
|
130
|
+
if (payload?.last_agent_message) {
|
|
131
|
+
telemetry.assistantPreview = preview(payload.last_agent_message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const timed = records.filter((record) => timestampMs(record?.timestamp) > 0);
|
|
137
|
+
const latest = timed.at(-1);
|
|
138
|
+
if (!latest) return telemetry;
|
|
139
|
+
telemetry.lastEventAt = timestampMs(latest.timestamp);
|
|
140
|
+
|
|
141
|
+
const payload = latest.payload;
|
|
142
|
+
const payloadType = String(payload?.type ?? "");
|
|
143
|
+
// A tool call that is the newest record is still running: its output would appear after it.
|
|
144
|
+
const toolName = toolNameFor(payload);
|
|
145
|
+
if (toolName) {
|
|
146
|
+
telemetry.state = "tool";
|
|
147
|
+
telemetry.toolName = toolName;
|
|
148
|
+
telemetry.toolTarget = describeCodexToolTarget(payload);
|
|
149
|
+
return telemetry;
|
|
150
|
+
}
|
|
151
|
+
telemetry.state = payloadType === "task_complete" ? "idle" : "thinking";
|
|
152
|
+
return telemetry;
|
|
153
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { execFile } from "node:child_process";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
|
+
import { detectTabInventorySupport } from "../native/tab-inventory.mjs";
|
|
9
10
|
|
|
10
11
|
const execFileAsync = promisify(execFile);
|
|
11
12
|
|
|
@@ -83,6 +84,10 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
83
84
|
env,
|
|
84
85
|
execFileAsyncImpl: options.execFileAsyncImpl,
|
|
85
86
|
});
|
|
87
|
+
const tabInventory = await detectTabInventorySupport({
|
|
88
|
+
execFileAsync: options.execFileAsyncImpl ?? execFileAsync,
|
|
89
|
+
env,
|
|
90
|
+
});
|
|
86
91
|
const blockers = [];
|
|
87
92
|
const warnings = [];
|
|
88
93
|
|
|
@@ -117,6 +122,12 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
117
122
|
);
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
if (windowManager === "niri" && !tabInventory.available) {
|
|
126
|
+
warnings.push(
|
|
127
|
+
`Hidden Ghostty tabs that were never shown cannot be placed: ${tabInventory.detail}. Tabs shown while the strip runs are still remembered.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
120
131
|
return {
|
|
121
132
|
ok: blockers.length === 0,
|
|
122
133
|
backend,
|
|
@@ -126,6 +137,7 @@ export async function assessActivityStripCompatibility(options = {}) {
|
|
|
126
137
|
alignmentMode: "layer-shell",
|
|
127
138
|
primaryDisplayOnly: true,
|
|
128
139
|
clickThroughDefault: env.PI_ACTIVITY_STRIP_CLICK_THROUGH === "1",
|
|
140
|
+
tabInventory,
|
|
129
141
|
blockers,
|
|
130
142
|
warnings,
|
|
131
143
|
};
|
|
@@ -143,6 +155,7 @@ export function formatCompatibilityReport(report) {
|
|
|
143
155
|
`Alignment mode: ${report.alignmentMode}`,
|
|
144
156
|
`Primary-display only: ${report.primaryDisplayOnly ? "yes" : "no"}`,
|
|
145
157
|
`Click-through mode: ${report.clickThroughDefault ? "enabled by environment" : "disabled (interactive default)"}`,
|
|
158
|
+
`Hidden-tab inventory: ${report.tabInventory?.available ? "available" : "unavailable"} (${report.tabInventory?.detail ?? "unknown"})`,
|
|
146
159
|
];
|
|
147
160
|
|
|
148
161
|
if (typeof report.displayCount === "number") {
|
package/src/common/constants.mjs
CHANGED
|
@@ -25,5 +25,7 @@ export const ACTIVITY_STRIP_BROADCAST_TICK_MS = 1000;
|
|
|
25
25
|
export const ACTIVITY_STRIP_SEND_THROTTLE_MS = 120;
|
|
26
26
|
export const ACTIVITY_STRIP_CONNECT_TIMEOUT_MS = 450;
|
|
27
27
|
export const ACTIVITY_STRIP_START_TIMEOUT_MS = 5000;
|
|
28
|
+
/** How long `stop` waits for the runtime to exit; an in-flight read-only `ak` query can take 8 s. */
|
|
29
|
+
export const ACTIVITY_STRIP_STOP_TIMEOUT_MS = 15_000;
|
|
28
30
|
export const ACTIVITY_STRIP_EVENT_STALL_MS = 15 * 60_000;
|
|
29
31
|
export const ACTIVITY_STRIP_FLUSH_RETRY_DELAYS_MS = [250, 750];
|
|
@@ -19,6 +19,15 @@ export interface SessionSnapshot {
|
|
|
19
19
|
publisherCount?: number;
|
|
20
20
|
publisherIds?: string[];
|
|
21
21
|
publisherRecordKeys?: string[];
|
|
22
|
+
publisherSessionIds?: string[];
|
|
23
|
+
akTasks?: AkTaskChip[];
|
|
24
|
+
akTaskOverflow?: number;
|
|
25
|
+
/** Niri window id; for a hidden tab, the window hosting it. */
|
|
26
|
+
windowId?: number | null;
|
|
27
|
+
/** Niri workspace `idx`, the number the operator sees, never the internal workspace id. */
|
|
28
|
+
workspaceIdx?: number | null;
|
|
29
|
+
placement?: "title" | "host" | "binding";
|
|
30
|
+
surfaceVisible?: boolean;
|
|
22
31
|
cwd: string;
|
|
23
32
|
repoLabel: string;
|
|
24
33
|
sessionName: string;
|
|
@@ -38,6 +47,32 @@ export interface SessionSnapshot {
|
|
|
38
47
|
errorMessage: string;
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
export type AkTaskChipState = "active" | "deferred" | "orphaned";
|
|
51
|
+
|
|
52
|
+
/** One read-only AK task reference joined onto a terminal card by the native projection. */
|
|
53
|
+
export interface AkTaskChip {
|
|
54
|
+
id: number;
|
|
55
|
+
title: string;
|
|
56
|
+
state: AkTaskChipState;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A live-lease task claim parsed from read-only `ak task list` output. */
|
|
60
|
+
export interface AkTaskClaim {
|
|
61
|
+
id: number;
|
|
62
|
+
title: string;
|
|
63
|
+
repo: string;
|
|
64
|
+
sessionId: string;
|
|
65
|
+
leaseExpiresAt: number;
|
|
66
|
+
claimedAt: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A task with an active deferral parsed from read-only `ak task deferred` output. */
|
|
70
|
+
export interface AkTaskDeferred {
|
|
71
|
+
id: number;
|
|
72
|
+
title: string;
|
|
73
|
+
repo: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
export interface BrokerSnapshot {
|
|
42
77
|
generatedAt: number;
|
|
43
78
|
sessions: SessionSnapshot[];
|