pi-onlyne 0.2.5 → 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/README.md CHANGED
@@ -19,17 +19,20 @@ Onlyne is deliberately narrow:
19
19
 
20
20
  `pi-onlyne` connects pi to an existing Onlyne workspace and exposes Onlyne as native pi tools.
21
21
 
22
+ Onlyne channels are singleton-routed: each enabled channel has one `bind_conversation_id` set in config or by sending `/handshake` from the desired conversation, so pi tools take `channelId` only.
23
+
22
24
  With this extension, a pi agent can:
23
25
 
24
26
  - watch an Onlyne workspace for inbound IM messages
25
27
  - surface inbound messages into the current pi session
26
28
  - reply to the current inbound message
27
- - send a message to a specific channel conversation
29
+ - send a message to a channel's configured conversation
28
30
  - broadcast the same message to multiple conversations
29
31
  - inject a local loopback activation so background scripts can wake the session
32
+ - share Onlyne's FIFO consume cursor so `.onlyne/channels/<channel>/out` does not re-read messages already surfaced to pi
30
33
  - mark an inbound message as intentionally not replied
31
34
 
32
- Messages are Markdown by default, matching normal agent output. Use `rawText: true` only when the message must be sent literally.
35
+ Messages are Markdown by default, matching normal agent output. Use `rawText: true` only when the message must be sent literally. Onlyne can also expose FIFO IO under `.onlyne/channels/<channel>/in|out`; pi-onlyne stays on the socket/event API and advances the shared consume cursor after delivering inbound follow-ups.
33
36
 
34
37
  ## Install
35
38
 
@@ -82,9 +85,9 @@ When a message arrives through Onlyne, pi receives it as a follow-up message. Th
82
85
 
83
86
  ```text
84
87
  onlyne_reply({ text })
85
- onlyne_send({ channelId, conversationId, text, rawText? })
88
+ onlyne_send({ channelId, text, rawText? })
86
89
  onlyne_broadcast({ targets, text, rawText? })
87
- onlyne_loopback({ text, conversationId?, rawText? })
90
+ onlyne_loopback({ text, rawText? })
88
91
  onlyne_mark_no_reply({ reason? })
89
92
  ```
90
93
 
@@ -93,7 +96,6 @@ onlyne_mark_no_reply({ reason? })
93
96
  ```ts
94
97
  onlyne_send({
95
98
  channelId: "telegram",
96
- conversationId: "123456",
97
99
  text: "# Build report\n\nAll checks passed."
98
100
  })
99
101
  ```
@@ -103,7 +105,6 @@ onlyne_send({
103
105
  ```ts
104
106
  onlyne_send({
105
107
  channelId: "telegram",
106
- conversationId: "123456",
107
108
  text: "# not a heading",
108
109
  rawText: true
109
110
  })
@@ -114,10 +115,10 @@ onlyne_send({
114
115
  ```ts
115
116
  onlyne_broadcast({
116
117
  targets: [
117
- { channelId: "telegram", conversationId: "123456" },
118
- { channelId: "feishu", conversationId: "oc_xxx" }
118
+ { channelId: "telegram" },
119
+ { channelId: "feishu" }
119
120
  ],
120
- text: "# Release shipped\n\nVersion 0.2.3 is live."
121
+ text: "# Release shipped\n\nVersion 0.3.0 is live."
121
122
  })
122
123
  ```
123
124
 
@@ -127,6 +128,8 @@ From any local script, inject an inbound message into the current Onlyne daemon:
127
128
 
128
129
  ```bash
129
130
  onlyne client '{"id":"wake","op":"loopback","text":"background job finished","raw_text":true}'
131
+ # or, with FIFO IO enabled by the daemon:
132
+ printf 'background job finished\n' > .onlyne/channels/loopback/in
130
133
  ```
131
134
 
132
135
  Pi treats channel `loopback` as wake-up-only: it sends a follow-up to the session, but does not expect `onlyne_reply`.
package/SPEC.md CHANGED
@@ -15,6 +15,8 @@ Pi extension for Onlyne. Onlyne remains a workspace-local IM broker; this extens
15
15
  - Send tools default to Markdown and may pass `raw_text: true` to Onlyne for literal text.
16
16
  - Broadcast sends concurrently with per-target retry and per-target results.
17
17
  - Loopback inbound messages wake Pi without creating a reply obligation.
18
+ - After pi surfaces an inbound follow-up, it calls `mark_io_consumed` so Onlyne FIFO `out_cursor = "consume"` stays synchronized with pi notifications.
19
+ - FIFO IO itself remains owned by the Onlyne daemon; pi-onlyne does not open `.onlyne/channels/*/in|out` directly.
18
20
 
19
21
  ## Config
20
22
 
@@ -35,9 +37,9 @@ Stored in project `.pi/onlyne.json`:
35
37
  ## Tools
36
38
 
37
39
  - `onlyne_reply({ text })`
38
- - `onlyne_send({ channelId, conversationId, text, rawText? })`
40
+ - `onlyne_send({ channelId, text, rawText? })`
39
41
  - `onlyne_broadcast({ targets, text, rawText? })`
40
- - `onlyne_loopback({ text, conversationId?, rawText? })`
42
+ - `onlyne_loopback({ text, rawText? })`
41
43
  - `onlyne_mark_no_reply({ reason? })`
42
44
 
43
45
  ## Deferred
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
- import { broadcast, connectOrSpawn, loopback, sendWithRetry, stopProcess, subscribe } from "./onlyne.js";
3
+ import { broadcast, connectOrSpawn, loopback, markConsumed, sendWithRetry, stopProcess, subscribe } from "./onlyne.js";
4
4
  import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
5
5
  import { findWorkspace } from "./workspace.js";
6
6
  const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped" };
7
7
  const textResult = (text, details) => ({ content: [{ type: "text", text }], details });
8
8
  const currentConfig = () => loadConfig(state.cwd);
9
- function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, text } : null; }
9
+ function inboundText(data) { const msg = data?.data?.data ?? data?.data ?? data; const channelId = msg.channel_id ?? msg.channelId; const conversationId = msg.conversation_id ?? msg.conversationId; const messageId = msg.message_id ?? msg.messageId; const text = msg.text ?? msg.content ?? msg.body; return channelId && conversationId && typeof text === "string" ? { channelId, conversationId, messageId, text } : null; }
10
+ function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
11
+ void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
10
12
  async function startWatch(pi) { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
11
13
  throw new Error("current workspace has no .onlyne configuration"); const conn = await connectOrSpawn(state.workspace); state.owner = conn.owner; state.child = conn.process; state.socket = subscribe(state.workspace.socketPath, (line) => { if (!line?.event || line.type !== "inbound_message")
12
14
  return; const inbound = inboundText(line); if (!inbound)
@@ -14,14 +16,17 @@ async function startWatch(pi) { state.workspace = findWorkspace(state.cwd); if (
14
16
  return; if (inbound.channelId === "loopback") {
15
17
  if (mode === "auto-handle")
16
18
  pi.sendUserMessage(`Onlyne loopback activation${inbound.conversationId ? ` (${inbound.conversationId})` : ""}:\n\n${inbound.text}`, { deliverAs: "followUp" });
19
+ consumeIfNotified(inbound);
17
20
  return;
18
- } state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle")
19
- pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" }); }); state.watching = true; return `watching ${state.workspace.root} (${state.owner})`; }
21
+ } state.currentInbound = { ...inbound, replied: false, noReply: false, reminders: 0 }; if (mode === "auto-handle") {
22
+ pi.sendUserMessage(`Onlyne inbound message from ${inbound.channelId}/${inbound.conversationId}:\n\n${inbound.text}\n\nReply with onlyne_reply, or call onlyne_mark_no_reply if no reply is needed.`, { deliverAs: "followUp" });
23
+ consumeIfNotified(inbound);
24
+ } }); state.watching = true; return `watching ${state.workspace.root} (${state.owner})`; }
20
25
  function stopWatch() { state.socket?.destroy(); state.socket = undefined; if (state.owner === "extension")
21
26
  stopProcess(state.child); state.child = undefined; state.watching = false; state.owner = "stopped"; return "watch stopped"; }
22
27
  async function reply(text) { if (!state.workspace)
23
28
  throw new Error("onlyne workspace not found"); const inbound = state.currentInbound; if (!inbound)
24
- throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId, conversationId: inbound.conversationId }, text, currentConfig().outbound.retry.attempts); if (res.ok)
29
+ throw new Error("no active inbound message"); const res = await sendWithRetry(state.workspace.socketPath, { channelId: inbound.channelId }, text, currentConfig().outbound.retry.attempts); if (res.ok)
25
30
  inbound.replied = true; return res; }
26
31
  export default function onlyne(pi) {
27
32
  pi.on("session_start", async (_event, ctx) => { state.cwd = ctx.cwd; state.workspace = findWorkspace(ctx.cwd); ctx.ui.setStatus("onlyne", state.workspace ? "onlyne: ready" : "onlyne: no .onlyne"); if (currentConfig().watch.autoStart && state.workspace) {
@@ -73,12 +78,12 @@ export default function onlyne(pi) {
73
78
  ctx.ui.notify(e instanceof Error ? e.message : String(e), "error");
74
79
  } } });
75
80
  pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with plain text to the current Onlyne inbound message.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
76
- pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to one Onlyne channel conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), conversationId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
81
+ pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
77
82
  throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
78
- pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many Onlyne channel conversations concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String(), conversationId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
83
+ pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many configured Onlyne channels concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
79
84
  throw new Error("onlyne workspace not found"); const cfg = currentConfig(); const results = await broadcast(state.workspace.socketPath, params.targets, params.text, cfg.outbound.retry.attempts, cfg.outbound.retry.concurrency, params.rawText ?? false); return textResult(JSON.stringify({ ok: results.every((r) => r.ok), results }), results); } }));
80
- pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown.", parameters: Type.Object({ text: Type.String(), conversationId: Type.Optional(Type.String()), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
81
- throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.conversationId ?? "self", params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
85
+ pi.registerTool(defineTool({ name: "onlyne_loopback", label: "Onlyne loopback", description: "Inject a local loopback activation message so scripts can wake the current Pi session. Set rawText=false for Markdown. FIFO alternative: write to .onlyne/channels/loopback/in.", parameters: Type.Object({ text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
86
+ throw new Error("onlyne workspace not found"); const res = await loopback(state.workspace.socketPath, params.text, params.rawText ?? true); return textResult(JSON.stringify(res), res); } }));
82
87
  pi.registerTool(defineTool({ name: "onlyne_mark_no_reply", label: "Onlyne no reply", description: "Mark the current Onlyne inbound message as intentionally not replied.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { if (state.currentInbound)
83
88
  state.currentInbound.noReply = true; return textResult("marked no reply", params); } }));
84
89
  }
package/dist/onlyne.d.ts CHANGED
@@ -5,7 +5,7 @@ export interface OnlyneRequest {
5
5
  id: string;
6
6
  op: string;
7
7
  channel_id?: string;
8
- conversation_id?: string;
8
+ message_id?: string;
9
9
  text?: string;
10
10
  format?: "plain" | "markdown";
11
11
  raw_text?: boolean;
@@ -13,7 +13,6 @@ export interface OnlyneRequest {
13
13
  }
14
14
  export interface SendTarget {
15
15
  channelId: string;
16
- conversationId: string;
17
16
  }
18
17
  export interface SendResult extends SendTarget {
19
18
  ok: boolean;
@@ -28,6 +27,7 @@ export declare function connectOrSpawn(ws: Workspace): Promise<{
28
27
  process?: ChildProcess;
29
28
  }>;
30
29
  export declare function stopProcess(child?: ChildProcess): void;
31
- export declare function loopback(socketPath: string, text: string, conversationId?: string, rawText?: boolean): Promise<any>;
30
+ export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
31
+ export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
32
32
  export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
33
33
  export declare function broadcast(socketPath: string, targets: SendTarget[], text: string, attempts: number, concurrency: number, rawText?: boolean): Promise<SendResult[]>;
package/dist/onlyne.js CHANGED
@@ -80,14 +80,17 @@ export async function connectOrSpawn(ws) {
80
80
  export function stopProcess(child) { if (!child || child.killed)
81
81
  return; child.kill("SIGTERM"); setTimeout(() => { if (!child.killed)
82
82
  child.kill("SIGKILL"); }, 1500).unref(); }
83
- export async function loopback(socketPath, text, conversationId = "self", rawText = true) {
84
- return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", conversation_id: conversationId, text, raw_text: rawText });
83
+ export async function loopback(socketPath, text, rawText = true) {
84
+ return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
85
+ }
86
+ export async function markConsumed(socketPath, messageId) {
87
+ return request(socketPath, { id: `consume-${Date.now()}`, op: "mark_io_consumed", message_id: messageId });
85
88
  }
86
89
  export async function sendWithRetry(socketPath, target, text, attempts, rawText = false) {
87
90
  let error = "unknown error";
88
91
  for (let i = 0; i < Math.max(1, attempts); i++) {
89
92
  try {
90
- const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId, conversation_id: target.conversationId, text, raw_text: rawText });
93
+ const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId, text, raw_text: rawText });
91
94
  if (res.ok)
92
95
  return { ...target, ok: true };
93
96
  error = res.error?.message ?? JSON.stringify(res.error ?? res);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-onlyne",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension tools for sending messages through Onlyne.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",