pi-open-deskos 0.2.0 → 0.3.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/LICENSE +21 -0
- package/README.md +4 -4
- package/README.zh-CN.md +4 -4
- package/package.json +2 -2
- package/src/events.ts +17 -2
- package/src/index.ts +59 -4
- package/src/session-replay.ts +38 -0
- package/src/types.ts +6 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Frad LEE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -37,11 +37,11 @@ Put them in the environment Pi runs with (for a desktop session, the same place
|
|
|
37
37
|
|
|
38
38
|
## What is reported
|
|
39
39
|
|
|
40
|
-
- The current session's live identity, goal, activity, and events, plus other sessions registered on this machine in `directory-sessions` metadata (written by `pi-utils` and `pi-keyboard`). UUID and timestamp-prefixed UUID aliases merge into one session.
|
|
40
|
+
- The current session's live identity, goal, activity, and events, plus other sessions registered on this machine in `directory-sessions` metadata (written by `pi-utils` and `pi-keyboard`). On resumed, reloaded, or forked reporter start, the current session replays its bounded durable JSONL tail before forwarding later live messages, so a resumed Pi does not appear as running with an empty Desk Link stream. UUID and timestamp-prefixed UUID aliases merge into one session.
|
|
41
41
|
- State: `running` requires working metadata, a live Pi process, and timestamps compatible with that process's lifetime; live idle/settled records are `settled`. Dead/invalid/reused PIDs, unverifiable processes, and explicitly exited records are `exited`. One bounded process-table read (1 MiB, 2-second timeout) checks all PIDs; only the newest unambiguous session registered to a PID can be live. For the current session, Pi's own idle/agent events take precedence.
|
|
42
42
|
- Original metadata start/update times, optional session name, goal, and activity/recap. Scanning does not invent a new activity timestamp or read session histories.
|
|
43
43
|
- Bounded session events: the newest contiguous tail of at most 300 events and 1,048,576 UTF-8 bytes per session, counting text and tool names. Event bodies preserve line structure with per-kind limits: user 8 KiB, thinking 4 KiB, tool call 4 KiB, assistant 16 KiB, and tool result 64 KiB.
|
|
44
|
-
- Tool results preserve their complete multiline Markdown, including tables, code fences, and whitespace, up to 65,536 UTF-8 bytes per result. All text parts are joined with two newlines; images and arbitrary result metadata are not sent. Any body shortened at its kind's byte limit carries `truncated: true` and never splits a Unicode code point. A result's optional `toolName` is a separate 200-character summary, not a prefix inserted into the Markdown. Session activity remains a short first-line summary.
|
|
44
|
+
- Tool results preserve their complete multiline Markdown, including tables, code fences, and whitespace, up to 65,536 UTF-8 bytes per result. All text parts are joined with two newlines; images and arbitrary result metadata are not sent. Any body shortened at its kind's byte limit carries `truncated: true` and never splits a Unicode code point. A result's optional `toolName` is a separate 200-character summary, not a prefix inserted into the Markdown. A tool call and the result that answers it both carry the optional `toolCallId` Pi wrote for that call, and a result Pi recorded as an error carries `isError: true`, so a consumer pairs a result with its own call and reads the outcome from Pi's record instead of from event order. Session activity remains a short first-line summary.
|
|
45
45
|
|
|
46
46
|
The inventory refreshes on session start and every 5 seconds after each scan, including while the link is offline. A refresh replaces only discovered entries: the current session's identity and events cannot be removed or overwritten by stale metadata. Shutdown/reload cancels refresh scheduling and invalidates pending results and reconnects.
|
|
47
47
|
|
|
@@ -69,7 +69,7 @@ The default registry is `~/.pi/agent/directory-sessions`. `PI_DIRECTORY_SESSIONS
|
|
|
69
69
|
- **No management of reported sessions.** Even as a Console, the package does not manage the sessions it reports. Control applies to Hosted Pi sessions the desk hosts, which are a different thing.
|
|
70
70
|
- **No answering a Hosted Pi mid-turn.** The desk's Pi host loads no extensions, and the SDK exposes no permission, approval, or ask-user surface, so a Hosted Pi cannot raise a request for you to answer. This is a limit of the host's configuration, not a missing feature.
|
|
71
71
|
- **No network discovery or pairing.** Local session metadata is discovered, but a machine that does not already know the address and token cannot register, and a Console must already know the control credential.
|
|
72
|
-
- **No history or credential scan.**
|
|
72
|
+
- **No other-session history or credential scan.** On resumed, reloaded, or forked start, the current Pi reads only the bounded tail of its own `sessionManager.getSessionFile()` JSONL file through the same event bounds; it never reads other sessions' histories, auth files, or arbitrary extra metadata. Other sessions contribute registry metadata only, not synthetic events. Current-session tool-result text **is sent** within the bounds above; it may include file contents or sensitive output returned by a tool. Configure the link only when that sharing is intended.
|
|
73
73
|
- **No encryption.** The Desk Link is a plain connection to a LAN-only listener. The control credential is kept off the wire so a capture yields no reusable execution token, but content itself is not encrypted. Do not expose the Desk Link Service beyond the local network.
|
|
74
74
|
|
|
75
75
|
## Check the link from inside Pi
|
|
@@ -88,7 +88,7 @@ The control connection is separate and does not reproduce that behavior: list, l
|
|
|
88
88
|
|
|
89
89
|
## Wire contract
|
|
90
90
|
|
|
91
|
-
Newline-delimited JSON over one TCP connection, version 1 for reporting. Inventory sessions carry `discovered: true`; direct current-session observations do not. The service can therefore prefer active direct observations over later-written metadata from another reporter. The reporter writes `hello`, `sessions`, `events`, and `bye`; the service may reply `ack` or `error`. The `prompt` reply reserved in version 1 is still unimplemented, and control does not use it. Frames split on LF only and tolerate a trailing CR. Result events carry raw Markdown in `text`, plus optional `toolName` and `truncated: true
|
|
91
|
+
Newline-delimited JSON over one TCP connection, version 1 for reporting. Inventory sessions carry `discovered: true`; direct current-session observations do not. The service can therefore prefer active direct observations over later-written metadata from another reporter. The reporter writes `hello`, `sessions`, `events`, and `bye`; the service may reply `ack` or `error`. The `prompt` reply reserved in version 1 is still unimplemented, and control does not use it. Frames split on LF only and tolerate a trailing CR. Result events carry raw Markdown in `text`, plus optional `toolName`, `toolCallId`, `isError: true`, and `truncated: true`; a tool call event carries the same optional `toolCallId` as the result that answers it. Append-only event records are split between whole events into frames of at most 1 MiB, including JSON escaping and the final LF; bodies are never split or flattened to fit a frame. Replacing `sessions` snapshots retain their separate 65,536-byte producer budget. An event whose exact identity envelope alone makes it impossible to fit is not sent, and the command reports the frame-limit error.
|
|
92
92
|
|
|
93
93
|
Control uses version 2 on its own connection to the same listener. The `control-hello` record carries the reporting token, protocol version, machine name, and this Pi session's identity, so two Pi sessions on one machine are distinguishable Consoles. The desk replies with a one-time `hmac-sha256` challenge; the proof binds `open-deskos-control-v2`, version `2`, nonce, machine, and Console session ID as newline-separated UTF-8 text. The separate Control Credential is the HMAC key and is never sent. Every request has a `requestId`; mutations also have a retry-stable `mutationId`; held attach traffic has an `attachmentId`; session-log events have a physical `position`. List, launch, and history are one-shot, while attach holds its connection. A version the desk does not accept is refused explicitly before credential failure. The canonical fixture is bundled at `fixtures/control-v2.json`. There is no detach record, no replay window, and no resync record.
|
|
94
94
|
|
package/README.zh-CN.md
CHANGED
|
@@ -37,11 +37,11 @@ export ODK_DESK_LINK_CONTROL_TOKEN="<control credential>" # 可选:使本机
|
|
|
37
37
|
|
|
38
38
|
## 上报内容
|
|
39
39
|
|
|
40
|
-
- 当前会话的实时身份、目标、activity 与事件,以及本机注册在 `directory-sessions` 元数据中的其他会话(由 `pi-utils` 与 `pi-keyboard` 写入)。UUID 与带时间戳前缀的 UUID 别名会合并为同一会话。
|
|
40
|
+
- 当前会话的实时身份、目标、activity 与事件,以及本机注册在 `directory-sessions` 元数据中的其他会话(由 `pi-utils` 与 `pi-keyboard` 写入)。resumed、reload 或 fork 后上报端启动时会先回放当前会话自己的有界 durable JSONL 尾部,再转发后续实时消息,避免 resumed Pi 被 Desk Link 显示为运行中却没有事件。UUID 与带时间戳前缀的 UUID 别名会合并为同一会话。
|
|
41
41
|
- 会话状态:元数据表示工作中、Pi 进程存活、时间戳匹配该进程生命周期时才为 `running`;存活的 idle/settled 会话为 `settled`。已结束、无效、被复用、无法核实的 PID 或明确退出的记录为 `exited`。每次扫描只读取一次有界进程表(1 MiB,2 秒超时),同一 PID 只有最新且无歧义的会话可视为存活。当前会话以 Pi 自身的 idle/agent 事件为准。
|
|
42
42
|
- 原始元数据的 start/update 时间、可选会话名、目标与 activity/recap。扫描不会凭空发明新的 activity 时间戳,也不读取会话历史。
|
|
43
43
|
- 有界会话事件:每会话最多保留最新连续的 300 条事件与 1,048,576 UTF-8 字节(计入正文与工具名)。正文保留换行并按类型限长:user 8 KiB、thinking 4 KiB、tool-call 4 KiB、assistant 16 KiB、tool result 64 KiB。
|
|
44
|
-
- 工具结果保留完整的多行 Markdown,包括表格、代码围栏和空白,每条正文最多 65,536 UTF-8 字节。所有文本块以两个换行连接;图片与任意结果元数据不上报。任一类型的正文超过自身字节上限时会带 `truncated: true`,且不会截断 Unicode 码点。可选 `toolName` 为独立的 200 字符摘要,不再作为前缀插入 Markdown
|
|
44
|
+
- 工具结果保留完整的多行 Markdown,包括表格、代码围栏和空白,每条正文最多 65,536 UTF-8 字节。所有文本块以两个换行连接;图片与任意结果元数据不上报。任一类型的正文超过自身字节上限时会带 `truncated: true`,且不会截断 Unicode 码点。可选 `toolName` 为独立的 200 字符摘要,不再作为前缀插入 Markdown 正文。工具调用与回应它的结果都携带 Pi 为该次调用写下的可选 `toolCallId`,被 Pi 记为错误的结果携带 `isError: true`,因此消费端能把结果与它自己的调用配对,并从 Pi 的记录而非事件顺序读出结果状态。会话 activity 仍为简短的首行摘要。
|
|
45
45
|
|
|
46
46
|
会话开始时以及每次扫描后每 5 秒刷新一次清单,链路离线期间同样刷新。刷新只替换被发现的条目:当前会话的身份与事件不会被陈旧元数据删除或覆盖。关闭/重载会取消刷新调度,并使待处理结果与重连失效。
|
|
47
47
|
|
|
@@ -69,7 +69,7 @@ export ODK_DESK_LINK_CONTROL_TOKEN="<control credential>" # 可选:使本机
|
|
|
69
69
|
- **不管理被上报的会话。** 即使作为 Console,package 也不管理自己上报的那些会话;控制只作用于 desk 托管的 Hosted Pi,那是另一回事。
|
|
70
70
|
- **不能回答 Hosted Pi 的中途请求。** desk 的 Pi host 不加载扩展,SDK 也不提供权限、审批或提问面,因此 Hosted Pi 无法产生需要你回答的请求。这是 host 配置的限制,不是缺失的功能。
|
|
71
71
|
- **无网络发现与配对。** 发现的是本地会话元数据;不知道地址与 token 的机器无法注册,Console 也必须事先知道控制凭据。
|
|
72
|
-
-
|
|
72
|
+
- **不扫描其他会话历史与凭据。** resumed、reload 或 fork 后当前 Pi 启动时只读取 `sessionManager.getSessionFile()` 指向的当前 JSONL 文件有界尾部,并按同一事件上限回放;不读取其他会话的历史、认证文件或任意额外元数据(其他会话只贡献注册表元数据,不合成事件)。当前会话的工具结果正文**会**在上述上限内上报,其中可能包含文件内容或工具返回的敏感输出;只有在你确实愿意共享时才配置该链路。
|
|
73
73
|
- **不加密。** Desk Link 是面向仅局域网监听的明文连接。控制凭据不上线,因此抓包拿不到可复用的执行凭据,但内容本身并未加密。请勿把 Desk Link Service 暴露到局域网之外。
|
|
74
74
|
|
|
75
75
|
## 在 Pi 内查看链路
|
|
@@ -88,7 +88,7 @@ export ODK_DESK_LINK_CONTROL_TOKEN="<control credential>" # 可选:使本机
|
|
|
88
88
|
|
|
89
89
|
## 线上契约
|
|
90
90
|
|
|
91
|
-
一条 TCP 连接上的换行分隔 JSON,上报为版本 1。清单发现的会话带 `discovered: true`,当前会话直接观察的记录不带该标记,使服务端优先采用活动中的直接观察,而非其他上报端读取的更晚元数据。上报端写 `hello`、`sessions`、`events`、`bye`;服务端可回 `ack`、`error`。版本 1 预留的 `prompt` 回复仍未实现,且控制不使用它。分帧只按 LF,并容忍尾部 CR。结果事件的 `text` 为原始 Markdown,可带 `toolName` 和 `truncated: true`。追加式 `events` 记录按完整事件拆成每帧最多 1 MiB 的批次,上限包括 JSON 转义和末尾 LF;不会为适应帧大小而拆分、压平正文。替换式 `sessions` 快照仍使用独立的 65,536 字节生产端预算。若完整身份字段导致单条事件无法装入一帧,该事件不会发送,命令会显示帧上限错误。
|
|
91
|
+
一条 TCP 连接上的换行分隔 JSON,上报为版本 1。清单发现的会话带 `discovered: true`,当前会话直接观察的记录不带该标记,使服务端优先采用活动中的直接观察,而非其他上报端读取的更晚元数据。上报端写 `hello`、`sessions`、`events`、`bye`;服务端可回 `ack`、`error`。版本 1 预留的 `prompt` 回复仍未实现,且控制不使用它。分帧只按 LF,并容忍尾部 CR。结果事件的 `text` 为原始 Markdown,可带 `toolName`、`toolCallId`、`isError: true` 和 `truncated: true`;工具调用事件携带与回应它的结果相同的可选 `toolCallId`。追加式 `events` 记录按完整事件拆成每帧最多 1 MiB 的批次,上限包括 JSON 转义和末尾 LF;不会为适应帧大小而拆分、压平正文。替换式 `sessions` 快照仍使用独立的 65,536 字节生产端预算。若完整身份字段导致单条事件无法装入一帧,该事件不会发送,命令会显示帧上限错误。
|
|
92
92
|
|
|
93
93
|
控制使用版本 2,走同一监听面上的独立连接。`control-hello` 携带上报 token、协议版本、机器名与当前 Pi 会话身份,因此同一机器上的两个 Pi 会话是两个可区分的 Console。desk 返回一次性 `hmac-sha256` challenge;证明内容把 `open-deskos-control-v2`、版本 `2`、nonce、机器名与 Console 会话 ID 以换行分隔的 UTF-8 文本绑定起来。独立 Control Credential 只作为 HMAC 密钥,绝不上线。每个请求都有 `requestId`,变更请求另有可用于重试对账的稳定 `mutationId`,保持连接的 attach 流量带 `attachmentId`,会话日志事件带物理 `position`。list / launch / history 为一枪式连接,attach 才保持连接。desk 不接受的版本会先明确报错,不会伪装成凭据错误。规范 fixture 随包位于 `fixtures/control-v2.json`。没有 detach 记录、没有重放窗口、也没有 resync 记录。
|
|
94
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-open-deskos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Report this machine's Pi sessions and their operating events to Open DeskOS over a package-initiated Desk Link, and optionally drive a Pi session Open DeskOS hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"README.zh-CN.md"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@fradser/pi-kit": "0.5.
|
|
33
|
+
"@fradser/pi-kit": "0.5.1"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/events.ts
CHANGED
|
@@ -53,7 +53,16 @@ export function boundSessionEvent(event: SessionEvent): SessionEvent | null {
|
|
|
53
53
|
truncated = true;
|
|
54
54
|
}
|
|
55
55
|
const toolName = event.kind === "result" ? boundEventText(event.toolName) : "";
|
|
56
|
-
|
|
56
|
+
// A call identity is an opaque short line, never a body, so it is bounded as one.
|
|
57
|
+
const toolCallId = event.kind === "result" || event.kind === "tool" ? boundEventText(event.toolCallId) : "";
|
|
58
|
+
return {
|
|
59
|
+
kind: event.kind,
|
|
60
|
+
text,
|
|
61
|
+
...(toolName ? { toolName } : {}),
|
|
62
|
+
...(toolCallId ? { toolCallId } : {}),
|
|
63
|
+
...(event.kind === "result" && event.isError === true ? { isError: true } : {}),
|
|
64
|
+
...(truncated ? { truncated: true } : {}),
|
|
65
|
+
};
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/** Keep a contiguous newest tail within both the event-count and UTF-8 budgets. */
|
|
@@ -112,6 +121,8 @@ function isToolCall(part: unknown): part is ToolCallPart {
|
|
|
112
121
|
interface MessageView {
|
|
113
122
|
role?: string;
|
|
114
123
|
toolName?: string;
|
|
124
|
+
toolCallId?: string;
|
|
125
|
+
isError?: boolean;
|
|
115
126
|
content?: unknown;
|
|
116
127
|
}
|
|
117
128
|
|
|
@@ -149,6 +160,10 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
|
|
|
149
160
|
kind: "result",
|
|
150
161
|
text: parts.filter(isText).map((part) => part.text).join("\n\n"),
|
|
151
162
|
...(typeof view.toolName === "string" ? { toolName: view.toolName } : {}),
|
|
163
|
+
// The call this result answers, and whether Pi recorded it as an error:
|
|
164
|
+
// a desk reads the outcome from this record instead of guessing one.
|
|
165
|
+
...(typeof view.toolCallId === "string" ? { toolCallId: view.toolCallId } : {}),
|
|
166
|
+
...(view.isError === true ? { isError: true } : {}),
|
|
152
167
|
});
|
|
153
168
|
return event ? [event] : [];
|
|
154
169
|
}
|
|
@@ -167,7 +182,7 @@ export function eventsFromMessage(message: unknown): SessionEvent[] {
|
|
|
167
182
|
// content. The one-line activity summary is made separately, in the
|
|
168
183
|
// reporter, and never replaces this body.
|
|
169
184
|
const text = summarizeToolCall(part.name, part.arguments).trim();
|
|
170
|
-
if (text) events.push({ kind: "tool", text });
|
|
185
|
+
if (text) events.push({ kind: "tool", text, ...(typeof part.id === "string" && part.id ? { toolCallId: part.id } : {}) });
|
|
171
186
|
continue;
|
|
172
187
|
}
|
|
173
188
|
// The reply body is added once, after its thinking and tool calls.
|
package/src/index.ts
CHANGED
|
@@ -1,19 +1,62 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
2
3
|
import { readDeskLinkConfig } from "./config.ts";
|
|
3
4
|
import { registerConsoleExtension } from "./console-extension.ts";
|
|
4
5
|
import { createControlTcpTransport } from "./control-transport.ts";
|
|
5
6
|
import { eventsFromMessage, promptFromMessage } from "./events.ts";
|
|
6
7
|
import { scanDirectorySessions, SessionDiscovery } from "./discovery.ts";
|
|
7
8
|
import { DeskReporter } from "./reporter.ts";
|
|
9
|
+
import { CurrentSessionReplay } from "./session-replay.ts";
|
|
8
10
|
import { createTcpTransport } from "./transport.ts";
|
|
9
11
|
|
|
10
12
|
const CONFIG_HINT = "set ODK_DESK_LINK_ADDRESS and ODK_DESK_LINK_TOKEN to report this machine";
|
|
13
|
+
/** Read only a bounded tail before it enters the reporter's own 300-event/1MiB retention pipeline. */
|
|
14
|
+
const MAX_DURABLE_SESSION_BYTES = 4 * 1024 * 1024;
|
|
11
15
|
|
|
12
16
|
function workspaceName(cwd: string): string {
|
|
13
17
|
const parts = cwd.split(/[/\\]/).filter((part) => part.length > 0);
|
|
14
18
|
return parts.length > 0 ? (parts[parts.length - 1] ?? cwd) : cwd;
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The current session manager points at its own trusted durable log. Read its
|
|
23
|
+
* bounded tail in chronological order; other sessions never enter this path.
|
|
24
|
+
*/
|
|
25
|
+
async function durableSessionEvents(sessionFile: string | undefined): Promise<ReturnType<typeof eventsFromMessage>> {
|
|
26
|
+
if (!sessionFile) return [];
|
|
27
|
+
try {
|
|
28
|
+
const handle = await open(sessionFile, "r");
|
|
29
|
+
try {
|
|
30
|
+
const stat = await handle.stat();
|
|
31
|
+
if (!stat.isFile() || stat.size <= 0) return [];
|
|
32
|
+
const bytes = Math.min(stat.size, MAX_DURABLE_SESSION_BYTES);
|
|
33
|
+
const tail = Buffer.alloc(bytes);
|
|
34
|
+
await handle.read(tail, 0, bytes, stat.size - bytes);
|
|
35
|
+
const text = tail.toString("utf8");
|
|
36
|
+
const lines = text.split("\n");
|
|
37
|
+
// A writer can have produced valid JSON before its terminating newline.
|
|
38
|
+
// It is still an incomplete append and must wait for message_end instead
|
|
39
|
+
// of being replayed now and then sent a second time live.
|
|
40
|
+
if (!text.endsWith("\n")) lines.pop();
|
|
41
|
+
// A bounded tail can begin mid-record; that first fragment is never an entry.
|
|
42
|
+
if (stat.size > bytes) lines.shift();
|
|
43
|
+
return lines.flatMap((line) => {
|
|
44
|
+
if (!line.trim()) return [];
|
|
45
|
+
try {
|
|
46
|
+
const entry = JSON.parse(line) as { type?: unknown; message?: unknown };
|
|
47
|
+
return entry.type === "message" && entry.message !== undefined ? eventsFromMessage(entry.message) : [];
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
} finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
17
60
|
/**
|
|
18
61
|
* Report this machine's Pi sessions and, with an independent credential, drive
|
|
19
62
|
* Hosted Pi sessions over a separate Desk Link v2 control connection.
|
|
@@ -25,10 +68,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
25
68
|
discover: () => scanDirectorySessions(),
|
|
26
69
|
onSnapshot: (sessions) => reporter.replaceDiscoveredSessions(sessions),
|
|
27
70
|
});
|
|
71
|
+
const replay = reporter === null ? null : new CurrentSessionReplay(durableSessionEvents);
|
|
28
72
|
let currentSessionId = "";
|
|
29
73
|
|
|
30
74
|
if (reporter && discovery) {
|
|
31
|
-
function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]):
|
|
75
|
+
function beginSession(ctx: Parameters<Parameters<ExtensionAPI["on"]>[1]>[1]): string {
|
|
32
76
|
if (currentSessionId.length > 0 && currentSessionId !== ctx.sessionManager.getSessionId()) {
|
|
33
77
|
reporter?.markStatus(currentSessionId, "exited");
|
|
34
78
|
}
|
|
@@ -44,12 +88,21 @@ export default function (pi: ExtensionAPI): void {
|
|
|
44
88
|
status: ctx.isIdle() ? "settled" : "running",
|
|
45
89
|
startedAt: Number.isFinite(startedAt) ? startedAt : Date.now(),
|
|
46
90
|
});
|
|
91
|
+
return currentSessionId;
|
|
47
92
|
}
|
|
48
93
|
|
|
49
|
-
pi.on("session_start", (
|
|
50
|
-
beginSession(ctx);
|
|
94
|
+
pi.on("session_start", async (event, ctx) => {
|
|
95
|
+
const sessionId = beginSession(ctx);
|
|
51
96
|
reporter.start();
|
|
52
97
|
discovery.start();
|
|
98
|
+
// A resumed/reloaded Pi can have a durable JSONL history that is longer
|
|
99
|
+
// than its in-memory branch. Start the gate for every session; only the
|
|
100
|
+
// resumed/reloaded/forked cases provide a durable tail. New sessions still
|
|
101
|
+
// queue their first message_end event behind this empty replay boundary.
|
|
102
|
+
const replayFile = ["resume", "reload", "fork"].includes((event as { reason?: string }).reason ?? "")
|
|
103
|
+
? ctx.sessionManager.getSessionFile?.()
|
|
104
|
+
: undefined;
|
|
105
|
+
await replay?.start(sessionId, replayFile, (id, events) => reporter?.recordEvents(id, events));
|
|
53
106
|
});
|
|
54
107
|
pi.on("session_info_changed", (event) => {
|
|
55
108
|
if (currentSessionId.length > 0) reporter.renameSession(currentSessionId, event.name);
|
|
@@ -60,9 +113,11 @@ export default function (pi: ExtensionAPI): void {
|
|
|
60
113
|
if (currentSessionId.length === 0) return;
|
|
61
114
|
const prompt = promptFromMessage(event.message);
|
|
62
115
|
if (prompt.length > 0) reporter.recordSession({ sessionId: currentSessionId, latestGoal: prompt });
|
|
63
|
-
|
|
116
|
+
const events = eventsFromMessage(event.message);
|
|
117
|
+
replay?.append(currentSessionId, events, (id, fresh) => reporter?.recordEvents(id, fresh));
|
|
64
118
|
});
|
|
65
119
|
pi.on("session_shutdown", () => {
|
|
120
|
+
replay?.invalidate();
|
|
66
121
|
discovery.stop();
|
|
67
122
|
reporter.markStatus(currentSessionId, "exited");
|
|
68
123
|
reporter.disconnect();
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { SessionEvent } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Replays one current session's durable tail before its later live events. The
|
|
5
|
+
* session_start handler awaits start(), so message_end cannot interleave with a
|
|
6
|
+
* snapshot. A numeric generation still rejects A -> B -> A stale reads.
|
|
7
|
+
*/
|
|
8
|
+
export class CurrentSessionReplay {
|
|
9
|
+
#generation = 0;
|
|
10
|
+
#sessionId = "";
|
|
11
|
+
readonly #read: (sessionFile: string) => Promise<SessionEvent[]>;
|
|
12
|
+
|
|
13
|
+
constructor(read: (sessionFile: string) => Promise<SessionEvent[]>) {
|
|
14
|
+
this.#read = read;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
invalidate(): void {
|
|
18
|
+
this.#generation += 1;
|
|
19
|
+
this.#sessionId = "";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async start(sessionId: string, sessionFile: string | undefined, deliver: (sessionId: string, events: SessionEvent[]) => void): Promise<void> {
|
|
23
|
+
const generation = ++this.#generation;
|
|
24
|
+
this.#sessionId = sessionId;
|
|
25
|
+
if (!sessionFile) return;
|
|
26
|
+
try {
|
|
27
|
+
const events = await this.#read(sessionFile);
|
|
28
|
+
if (this.#generation === generation && this.#sessionId === sessionId && events.length > 0) deliver(sessionId, events);
|
|
29
|
+
} catch {
|
|
30
|
+
// The durable reader is best effort; a later live message can still
|
|
31
|
+
// establish the stream after a transient file failure.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
append(sessionId: string, events: SessionEvent[], deliver: (sessionId: string, events: SessionEvent[]) => void): void {
|
|
36
|
+
if (this.#sessionId === sessionId && events.length > 0) deliver(sessionId, events);
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -29,6 +29,10 @@ export interface SessionEvent {
|
|
|
29
29
|
text: string;
|
|
30
30
|
/** Result-only label, kept outside the Markdown body. */
|
|
31
31
|
toolName?: string;
|
|
32
|
+
/** Tool and result only: the call identity Pi wrote, which pairs the two. */
|
|
33
|
+
toolCallId?: string;
|
|
34
|
+
/** Result only, and only when Pi recorded that result as an error. */
|
|
35
|
+
isError?: true;
|
|
32
36
|
/** Present only when a result or assistant body was shortened. */
|
|
33
37
|
/** Present only when the event body was shortened by its kind's limit. */
|
|
34
38
|
truncated?: true;
|
|
@@ -141,6 +145,8 @@ export interface ThinkingPart {
|
|
|
141
145
|
|
|
142
146
|
export interface ToolCallPart {
|
|
143
147
|
type: "toolCall";
|
|
148
|
+
/** Pi's identity for this call; its result echoes the same value. */
|
|
149
|
+
id?: string;
|
|
144
150
|
name: string;
|
|
145
151
|
arguments: unknown;
|
|
146
152
|
}
|