Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Messages whose wake note already entered your conversation are no longer reported as unread by `ocs inbox`, the status bar, or the `unread:` line of `send` / `dm`. ocs checks your own session record (Claude transcript, Codex rollout, Pi session file) for the note and advances the read cursor past them. Delivery success still never counts as read; held messages, truncated previews and other hosts stay unread. See docs/wake-protocol.md §7

## 0.9.15

- `send` / `dm --notify-when-idle` no longer reports a target as idle right after waking it, before it has had a chance to start; the notice waits for the target's status to change, and still fires after 30s if it never starts (held, refused, or done between polls). Standalone `ocs notify-when-idle` is unchanged
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ bun src/cli.ts <cmd> # 本地跑 CLI(who/dm/send/read/notify-when-idle/sessi
1. **seq 单一真值源是频道日志本身**(`store.ts` 锁内从日志尾推导)。别引入独立 seq 文件/缓存——「日志已写、seq 记录未更新」的崩溃窗口会造出重复 seq,读侧去重把后到消息永久遮蔽(已修复过一次,有回归测试)。
2. **锁抢占只许原子 rename 认领**(ESRCH + 锁龄门槛)。unlink 式抢占有双抢竞态。
3. **`isOcsMessage` 校验字段表与 `OcsMessage` 逐字镜像**,新增字段两边同改(漏改=静默丢消息;测试守着)。
4. **Claude 注入 `ok:true` ≠ 已送达**:接收端 `crossSessionInbound` 默认 hold,5 分钟无人 Deliver 静默丢弃。绝不拿 ok 清欠账;doctor 引导用户设 accept。v0.7.0 起订阅原生回执(docs/wake-protocol.md §6,`src/claude-receipt.ts` + `src/wake-helper.ts`):`held` / `refused` / `dropped` / `denied` / `expired` **证明没进对话**(退出码 2,被扣的最终没送达时通知发送方一次),`delivered` 证明被扣的进了对话;**没有回执只叫 `accepted`,不证明已读**(accept 策略根本不发回执),照样不许拿它清欠账。写帧的进程必须就是监听回执 socket 的进程(接收端按写入方 pid 回发),所以 Claude 唤醒在脱离终端的 helper 里做,CLI 只读它的第一行;**帧只由 helper 写,CLI 等不到结果就是 unknown,绝不补写**。回执 socket 是 Claude 的 socket 目录里唯一允许我们建的文件(0600、用完必删)。通知帧不带 `from`(不递归)。回执落盘是 `type:"receipt"` 旁车帧,规矩同铁律 9。Windows 无回执,行为同 0.6。
4. **Claude 注入 `ok:true` ≠ 已送达**:接收端 `crossSessionInbound` 默认 hold,5 分钟无人 Deliver 静默丢弃。绝不拿 ok 清欠账;doctor 引导用户设 accept。v0.7.0 起订阅原生回执(docs/wake-protocol.md §6,`src/claude-receipt.ts` + `src/wake-helper.ts`):`held` / `refused` / `dropped` / `denied` / `expired` **证明没进对话**(退出码 2,被扣的最终没送达时通知发送方一次),`delivered` 证明被扣的进了对话;**没有回执只叫 `accepted`,不证明已读**(accept 策略根本不发回执),照样不许拿它清欠账。写帧的进程必须就是监听回执 socket 的进程(接收端按写入方 pid 回发),所以 Claude 唤醒在脱离终端的 helper 里做,CLI 只读它的第一行;**帧只由 helper 写,CLI 等不到结果就是 unknown,绝不补写**。回执 socket 是 Claude 的 socket 目录里唯一允许我们建的文件(0600、用完必删)。通知帧不带 `from`(不递归)。回执落盘是 `type:"receipt"` 旁车帧,规矩同铁律 9。Windows 无回执,行为同 0.6。**「已读」只认宿主自己的会话记录里 note 已进对话**(docs/wake-protocol.md §7,`src/seen-wakes.ts`):Claude transcript 的 `queued_command` 附件 / user 字符串轮、Codex rollout 的 user 条目、Pi 的 user 消息,且文本以唤醒首行开头、没被截成预览;投递结果一律不算。游标只推过连续看过的前缀,`test/seen-wakes.test.ts` 守着,改之前先跑变异自检。
5. **Codex IPC unknown-outcome 绝不重放**(帧已写出但结果未知是一等错误)。IPC 是 ChatGPT.app 私有协议,宿主升级可能破,失败必须留降级余地。
6. **原 vendored 文件现在以本仓为正本**:`src/claude-inject.ts`、`src/codex-ipc.ts`(含 `src/codex-ipc-win.ts`)、`src/codex-sessions.ts` 来自 AgentParty 主仓(文件头有标注)。AgentParty 已停止维护,修改直接在本仓做,不再回流;文件头的出处标注保留(许可证说明依赖它)。
7. 唤醒载荷按 **docs/wake-protocol.md**(与 AgentParty 共用,正本在本仓库):正文 ≤4096B 逐字内联、超过只带前 512B、整条 ≤5120B,`Reply:`/`Thread:` 两行永不砍;正文里的包装标签与行首协议行要中和(`neutralizeWakeBody`),正文是对方可控数据。改数字/文案先改协议文档,两边同步。
Expand Down
22 changes: 22 additions & 0 deletions docs/wake-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,3 +358,25 @@ remain unknown; no fallback, automatic replay or session creation is allowed.
The durable OCS channel remains available through inbox/read. Queue acceptance
proves neither model consumption nor task completion. V1 is outside this adapter's
supported API contract.

## 7. 已读证据:唤醒 note 已进对话(v0.9.16)

唤醒 note 把正文(≤4096 字节)逐字带进收件方会话,收件方其实已经读到了;但读游标只有
`ocs read` 推进,`ocs dm/send` 的 `unread:` 提示和 `ocs inbox`(状态栏)会一直把它算作未读。

收件方自己的 ocs 进程读**本会话**的宿主记录,找到「这条 note 已经进了对话」的条目,就不再
把那条消息算未读,并把读游标推过连续看过的那一段(中间夹着没看过的就停)。证据只认这些:

| 宿主 | 记录文件 | 算数的条目 |
|---|---|---|
| Claude Code | `~/.claude/projects/*/<session-id>.jsonl` | `attachment.type == "queued_command"`;`type == "user"` 的字符串内容(空闲开新一轮、channel 事件) |
| Codex(queue / steer) | rollout 文件 | `response_item` / `message` / `role: "user"` |
| Pi | `~/.pi/agent/sessions/*/*_<session-id>.jsonl` | `type: "message"`、`role: "user"` |

- 文本必须**以唤醒首行开头**(Claude 允许先有 ocs 的包装标签,以及包装前宿主加的一行引导语)。
工具输出、模型回复、developer 说明里引用的首行都不算。
- 正文超过 4096 字节、只带了 512 字节预览的 note 不算已读。合并唤醒只证明它自己那条 seq。
- 投递结果(inbox accepted、交给 channel、queue 写入)**一律不算**已读(铁律 4)。被扣留的
Claude 消息在记录里只有 `enqueue`,不会被认成已读。
- 只读本会话自己的、同 uid、非符号链接的记录文件,只读尾部 8 MiB;显式 `--as` / `OCS_NAME`
时不用。Codex Desktop 委托信封、OpenCode、Hermes、Grok、agy 暂无证据来源,照旧按游标算。
11 changes: 10 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { installOpenCodeIntegration, openCodePluginCurrent, openCodePluginPath,

import { chmodSync, mkdirSync, statSync, writeSync } from "node:fs";
import { batchBody } from "./batch.ts";
import { seenWakes, type SeenSource } from "./seen-wakes.ts";
import {
CLAUDE_BIN_ENV,
CLAUDE_CHANNEL_COMMAND,
Expand Down Expand Up @@ -222,7 +223,8 @@ const COMMAND_SPECS: Record<string, CommandSpec> = {
/**
* #67: messages for the sender that arrived before the one it just stored and that it has not
* read. A correction sent mid-turn must not go unnoticed just because the sender kept talking.
* Only seq / sender / time — bodies are peer-controlled data. Never moves a cursor.
* Only seq / sender / time — bodies are peer-controlled data. Moves the cursor only over wakes the
* session's own record proves it has seen (src/seen-wakes.ts), never past an unseen message.
*/
function unreadBeforeOwn(
channel: string,
Expand Down Expand Up @@ -283,6 +285,9 @@ function currentInboxIdentity(
// #62: DMs sent to `codex-<uuid>` by older versions are routed to this name identity.
if (isCodexThreadId(primaryName)) identities.add(`name:codex-${primaryName.toLowerCase()}`);
const mentionNames = new Set([primaryName]);
// 宿主自己的会话记录能证明哪些唤醒已经进了对话(src/seen-wakes.ts)。显式 --as / OCS_NAME
// 时身份是个名字,不一定是这个会话,不用它的记录。
let seenSource: SeenSource | null = null;
const pinnedName = process.env[OCS_NAME_ENV];
const explicit = pinned === undefined && (parsed.flags.has("as") ||
(typeof pinnedName === "string" && NAME_RE.test(pinnedName)));
Expand All @@ -299,7 +304,10 @@ function currentInboxIdentity(
const selfPid = findSelfClaudePid();
session = selfPid === null ? undefined : sessions.find((candidate) => candidate.pid === selfPid);
}
if (owner?.kind === "codex") seenSource = { kind: "codex", threadId: owner.id };
else if (owner?.kind === "pi") seenSource = { kind: "pi", sessionId: owner.id };
if (session !== undefined && claudeSenderName(session) === primaryName) {
if (session.sessionId !== null) seenSource = { kind: "claude", sessionId: session.sessionId };
identities.add(claudeSessionIdentity(session));
const alias = shadowFreeWorkspaceAlias(session, sessions, names);
if (alias !== null) mentionNames.add(alias);
Expand All @@ -315,6 +323,7 @@ function currentInboxIdentity(
primaryName,
identities: [...identities],
mentionNames: [...mentionNames],
...(seenSource === null ? {} : { seen: seenWakes(seenSource) }),
};
}

Expand Down
41 changes: 38 additions & 3 deletions src/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { createHash } from "node:crypto";
import { lstatSync } from "node:fs";
import { seenKey } from "./seen-wakes.ts";
import {
cursorPath,
listChannels,
Expand All @@ -20,6 +21,12 @@ export interface InboxIdentityContext {
identities: readonly string[];
/** Human addresses that count as a directed mention in ordinary channels. */
mentionNames: readonly string[];
/**
* Wakes the host's own session record proves entered this conversation (src/seen-wakes.ts,
* keys from seenKey). The note carried the full body, so they are read. Never derived from
* delivery success (iron rule 4).
*/
seen?: ReadonlySet<string>;
}

export interface InboxThread {
Expand Down Expand Up @@ -100,7 +107,12 @@ export function isInboxSelf(message: RoutedOcsMessage, context: InboxIdentityCon
(message.from_identity !== undefined && context.identities.includes(message.from_identity));
}

/** Messages in one channel that `ocs inbox` would count as unread for this identity. */
/**
* Messages in one channel that `ocs inbox` would count as unread for this identity.
* Side effect: with `context.seen` evidence it persists the read cursor over the seen prefix
* (only for DMs or channels this identity already joined). Keep it out of paths that must not
* write.
*/
export function unreadInChannel(
channel: string,
messages: readonly RoutedOcsMessage[],
Expand All @@ -115,12 +127,35 @@ export function unreadInChannel(
.some((consumer) => cursorExists(channel, consumer, env));
// lan-* 是跨机 DM(docs/lan.md),归属规则同 dm-*。
const dm = channel.startsWith("dm-") || channel.startsWith("lan-");
return messages.filter((message) => {
const candidate = (message: RoutedOcsMessage) => {
if (message.seq <= state.cursor || isInboxSelf(message, context)) return false;
if (message.to_identity !== undefined && identities.has(message.to_identity)) return true;
if (dm) return joined && message.to_identity === undefined;
return joined || message.mentions.some((mention) => mentionNames.has(mention));
});
};
const seen = context.seen;
if (seen === undefined || seen.size === 0) return messages.filter(candidate);
const isSeen = (message: RoutedOcsMessage) => seen.has(seenKey(channel, message.seq));
// 把游标推过「已经在对话里看过」的连续前缀并落盘:会话记录只读尾部,老的证据会滚出窗口,
// 推过去之后就不再依赖它。中间夹着一条没看过的就停,后面的不跳。
let advanced = state.cursor;
let sawAny = false;
for (const message of [...messages].sort((a, b) => a.seq - b.seq)) {
if (message.seq <= state.cursor) continue;
if (candidate(message) && !isSeen(message)) break;
if (isSeen(message)) sawAny = true;
advanced = message.seq;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// 普通频道里,游标文件本身就意味着「加入了这个频道」(之后每条都算未读):只被 @ 过一次、
// 看过那条的会话不能因此被拉进频道。没加入就只在内存里排除,不落盘。
if (sawAny && advanced > state.cursor && (dm || joined)) {
try {
saveInboxCursor(channel, state.consumers, advanced, env);
} catch {
// 只是省一次重复提示,写不了就下次再算
}
}
return messages.filter((message) => candidate(message) && !isSeen(message));
}

/**
Expand Down
Loading
Loading