send-context 0.1.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 +153 -0
- package/dist/adapters/claude.js +77 -0
- package/dist/adapters/index.js +34 -0
- package/dist/adapters/opencode.js +75 -0
- package/dist/adapters/pi.js +70 -0
- package/dist/adapters/types.js +12 -0
- package/dist/commands/export.js +196 -0
- package/dist/commands/receive.js +139 -0
- package/dist/core/crypto.js +59 -0
- package/dist/core/exec.js +60 -0
- package/dist/core/formatter.js +58 -0
- package/dist/core/link.js +44 -0
- package/dist/core/paths.js +29 -0
- package/dist/core/session-store.js +45 -0
- package/dist/core/transport.js +42 -0
- package/dist/index.js +30 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shafiq Imtiaz
|
|
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
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# send-context
|
|
2
|
+
|
|
3
|
+
> Relay an AI coding-agent session from one developer to another through an encrypted, ephemeral link.
|
|
4
|
+
|
|
5
|
+
`send-context` is an agent-agnostic CLI for passing live context between AI coding agents — across machines, across people, across tools. A developer in one timezone exports their session; a teammate in another runs one command to pick up exactly where they left off, with the context injected straight into *their* agent.
|
|
6
|
+
|
|
7
|
+
The session is distilled into a structured **Context Handoff Skill** document, encrypted on your machine, and stored behind a short-lived link. The transport layer only ever sees ciphertext.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pi / Claude Code / OpenCode pi / Claude Code / OpenCode
|
|
11
|
+
│ ▲
|
|
12
|
+
│ extract + format inject prompt │
|
|
13
|
+
▼ │
|
|
14
|
+
┌─────────────────┐ encrypt send-context://… decrypt ┌─────────────────┐
|
|
15
|
+
│ send-context export │ ───────────► edge KV (24h) ────────► │ send-context receive │
|
|
16
|
+
└─────────────────┘ (ciphertext only) └─────────────────┘
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **Agent-agnostic** — works with pi, [Claude Code](https://claude.com/claude-code), and [OpenCode](https://opencode.ai), via a small adapter per agent.
|
|
22
|
+
- **The Context Handoff Skill Standard** — sessions become a dense, 6-section Markdown brief (objective, state, completed, failed approaches, next steps, raw appendix) instead of noisy chat logs.
|
|
23
|
+
- **Zero-knowledge transport** — AES-256-GCM encryption happens client-side; the server stores only encrypted blobs that expire after 24 hours.
|
|
24
|
+
- **Wrapper injection** — `send-context receive <link> -- <agent> "prompt"` launches the receiver's own agent with the context pre-loaded.
|
|
25
|
+
- **No native dependencies** — pure TypeScript on Node's built-in `crypto`; installs cleanly on any OS.
|
|
26
|
+
- **Serverless backend** — a ~90-line Deno Deploy worker backed by Deno KV. No credit card, no infrastructure to babysit.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
1. **Export** detects the active agent, extracts its session, and lets you curate what to send. You add a short written brief and pick which raw messages to attach.
|
|
31
|
+
2. The brief is rendered into the Context Handoff Skill template, encrypted with a password you choose, and uploaded. You get a `send-context://` link.
|
|
32
|
+
3. **Receive** downloads the blob, decrypts it locally, wraps it in an injection prompt, and spawns the receiving agent with that prompt as its opening message.
|
|
33
|
+
|
|
34
|
+
> [!NOTE]
|
|
35
|
+
> The password travels in the link fragment (`#…`), which is only ever processed client-side. Share the link over a channel you trust, or omit the fragment and share the password separately — `receive` will prompt for it.
|
|
36
|
+
|
|
37
|
+
## Getting started
|
|
38
|
+
|
|
39
|
+
### Prerequisites
|
|
40
|
+
|
|
41
|
+
- Node.js 20+
|
|
42
|
+
- One of: `pi`, `claude`, or `opencode` with at least one session in the project directory
|
|
43
|
+
- [Deno](https://deno.com) — only if you want to deploy or run the transport worker yourself
|
|
44
|
+
|
|
45
|
+
### Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install
|
|
49
|
+
npm run build # compiles src/ to dist/
|
|
50
|
+
node dist/index.js --help
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To install the `send-context` command globally:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npm install -g .
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Deploy the transport
|
|
60
|
+
|
|
61
|
+
The transport runs on **Deno Deploy + Deno KV** (`worker/main.ts`). It stores only encrypted payloads, each with a native 24-hour TTL.
|
|
62
|
+
|
|
63
|
+
**From GitHub (no local Deno needed):** push the repo, then create a project at [console.deno.com](https://console.deno.com) linked to it.
|
|
64
|
+
|
|
65
|
+
> [!IMPORTANT]
|
|
66
|
+
> Set **App Directory** to `worker` and **Entrypoint** to `main.ts`. If the app directory is left at the repository root, the build auto-detects the Node CLI in `src/` and fails. Leave install/build commands blank.
|
|
67
|
+
|
|
68
|
+
**From the CLI:**
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
deno install -gArf jsr:@deno/deployctl # one-time
|
|
72
|
+
cd worker
|
|
73
|
+
deno task dev # local test at http://localhost:8000
|
|
74
|
+
deno task deploy # deploys --prod, prints your *.deno.net host
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
> [!WARNING]
|
|
78
|
+
> Deno KV caps each value at 64 KiB, so payloads are limited to ~60 KB. A curated context handoff is far smaller; if you hit the limit, attach fewer appendix messages.
|
|
79
|
+
|
|
80
|
+
## Usage
|
|
81
|
+
|
|
82
|
+
### Send a context handoff
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
SEND_CONTEXT_WORKER=your-project.deno.net node dist/index.js export
|
|
86
|
+
# or pass the host and agent explicitly:
|
|
87
|
+
node dist/index.js export --worker your-project.deno.net --agent pi
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
You'll be guided through picking the agent, writing the brief, curating the appendix, and setting a password. The command prints a link:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
send-context://your-project.deno.net/<id>#<password>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Receive a context handoff
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
# Launch an agent with the context injected:
|
|
100
|
+
node dist/index.js receive 'send-context://…/<id>#<password>' -- pi "continue"
|
|
101
|
+
node dist/index.js receive 'send-context://…/<id>#<password>' -- claude "continue"
|
|
102
|
+
node dist/index.js receive 'send-context://…/<id>#<password>' -- opencode run "continue"
|
|
103
|
+
|
|
104
|
+
# Or just print the decrypted context handoff document:
|
|
105
|
+
node dist/index.js receive 'send-context://…/<id>#<password>'
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Supported agents
|
|
109
|
+
|
|
110
|
+
| Agent | Extraction | Notes |
|
|
111
|
+
| --- | --- | --- |
|
|
112
|
+
| **OpenCode** | `opencode session list` + `opencode export <id>` | Uses the native session-export CLI. |
|
|
113
|
+
| **pi** | reads `~/.pi/agent/sessions/<project>/*.jsonl` | No stdout JSON dump exists; reads the documented session transcript. |
|
|
114
|
+
| **Claude Code** | reads `~/.claude/projects/<project>/*.jsonl` | Same — reads the documented JSONL transcript. |
|
|
115
|
+
|
|
116
|
+
> [!TIP]
|
|
117
|
+
> Adding a new agent is a single file implementing the `AgentAdapter` interface (`getName()` + `extractSession()`). Register it in `src/adapters/index.ts`.
|
|
118
|
+
|
|
119
|
+
## The Context Handoff Skill Standard
|
|
120
|
+
|
|
121
|
+
Every export is formatted into a fixed Markdown structure so the receiving model gets actionable context immediately:
|
|
122
|
+
|
|
123
|
+
1. **Primary Objective**
|
|
124
|
+
2. **Current State & Blockers**
|
|
125
|
+
3. **Completed Steps** — don't repeat these
|
|
126
|
+
4. **Failed Approaches** — don't retry these
|
|
127
|
+
5. **Next Steps** — start here
|
|
128
|
+
6. **Raw Context Appendix** — curated messages for deep context
|
|
129
|
+
|
|
130
|
+
## Project structure
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
src/
|
|
134
|
+
index.ts CLI entry (commander)
|
|
135
|
+
core/
|
|
136
|
+
crypto.ts AES-256-GCM + scrypt
|
|
137
|
+
link.ts send-context:// codec
|
|
138
|
+
transport.ts upload/download client
|
|
139
|
+
formatter.ts Context Handoff Skill renderer
|
|
140
|
+
session-store.ts JSONL helpers
|
|
141
|
+
paths.ts exec.ts
|
|
142
|
+
adapters/ pi, claude, opencode + registry
|
|
143
|
+
commands/ export, receive
|
|
144
|
+
worker/
|
|
145
|
+
main.ts Deno Deploy + Deno KV worker
|
|
146
|
+
deno.json
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## Tech stack
|
|
150
|
+
|
|
151
|
+
- **CLI:** TypeScript, [commander](https://github.com/tj/commander.js), [@clack/prompts](https://github.com/bombshell-dev/clack)
|
|
152
|
+
- **Crypto:** Node.js built-in `crypto` (AES-256-GCM, scrypt)
|
|
153
|
+
- **Transport:** Deno Deploy + Deno KV
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ClaudeAdapter = void 0;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const paths_js_1 = require("../core/paths.js");
|
|
7
|
+
const session_store_js_1 = require("../core/session-store.js");
|
|
8
|
+
/**
|
|
9
|
+
* Claude Code adapter.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code has no stdout JSON session dump either; sessions live as
|
|
12
|
+
* documented JSONL files at ~/.claude/projects/<slug>/<id>.jsonl. We read the
|
|
13
|
+
* latest one for the current project directory.
|
|
14
|
+
*/
|
|
15
|
+
class ClaudeAdapter {
|
|
16
|
+
cwd;
|
|
17
|
+
constructor(cwd = process.cwd()) {
|
|
18
|
+
this.cwd = cwd;
|
|
19
|
+
}
|
|
20
|
+
getName() {
|
|
21
|
+
return "Claude Code";
|
|
22
|
+
}
|
|
23
|
+
static isPresent(cwd = process.cwd()) {
|
|
24
|
+
return (0, node_fs_1.existsSync)((0, node_path_1.join)(paths_js_1.CLAUDE_PROJECTS_ROOT, (0, paths_js_1.claudeSlug)(cwd)));
|
|
25
|
+
}
|
|
26
|
+
async extractSession() {
|
|
27
|
+
const dir = (0, node_path_1.join)(paths_js_1.CLAUDE_PROJECTS_ROOT, (0, paths_js_1.claudeSlug)(this.cwd));
|
|
28
|
+
const file = (0, session_store_js_1.findLatestJsonl)(dir);
|
|
29
|
+
if (!file) {
|
|
30
|
+
throw new Error(`No Claude Code session found for this project (${dir}).`);
|
|
31
|
+
}
|
|
32
|
+
const messages = [];
|
|
33
|
+
for (const line of (0, session_store_js_1.readJsonl)(file)) {
|
|
34
|
+
const entry = line;
|
|
35
|
+
if (entry.type !== "user" && entry.type !== "assistant")
|
|
36
|
+
continue;
|
|
37
|
+
if (!entry.message)
|
|
38
|
+
continue;
|
|
39
|
+
const text = renderContent(entry.message.content);
|
|
40
|
+
if (!text)
|
|
41
|
+
continue;
|
|
42
|
+
messages.push({ role: entry.type, content: text });
|
|
43
|
+
}
|
|
44
|
+
return messages;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.ClaudeAdapter = ClaudeAdapter;
|
|
48
|
+
function renderContent(content) {
|
|
49
|
+
if (typeof content === "string")
|
|
50
|
+
return content.trim();
|
|
51
|
+
if (!Array.isArray(content))
|
|
52
|
+
return "";
|
|
53
|
+
const out = [];
|
|
54
|
+
for (const part of content) {
|
|
55
|
+
if (part.type === "text" && part.text) {
|
|
56
|
+
out.push(part.text);
|
|
57
|
+
}
|
|
58
|
+
else if (part.type === "tool_use") {
|
|
59
|
+
out.push(`[tool: ${part.name ?? "tool"}] ${(0, session_store_js_1.summarizeValue)(part.input)}`);
|
|
60
|
+
}
|
|
61
|
+
else if (part.type === "tool_result") {
|
|
62
|
+
out.push(`[tool result] ${(0, session_store_js_1.summarizeValue)(extractToolResult(part.content))}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out.join("\n").trim();
|
|
66
|
+
}
|
|
67
|
+
function extractToolResult(content) {
|
|
68
|
+
if (typeof content === "string")
|
|
69
|
+
return content;
|
|
70
|
+
if (Array.isArray(content)) {
|
|
71
|
+
return content
|
|
72
|
+
.map((p) => (p && typeof p === "object" && "text" in p ? p.text : ""))
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
return (0, session_store_js_1.summarizeValue)(content);
|
|
77
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_AGENTS = void 0;
|
|
4
|
+
exports.createAdapter = createAdapter;
|
|
5
|
+
exports.detectAgents = detectAgents;
|
|
6
|
+
const pi_js_1 = require("./pi.js");
|
|
7
|
+
const claude_js_1 = require("./claude.js");
|
|
8
|
+
const opencode_js_1 = require("./opencode.js");
|
|
9
|
+
/** Construct an adapter by id. */
|
|
10
|
+
function createAdapter(id, cwd = process.cwd()) {
|
|
11
|
+
switch (id) {
|
|
12
|
+
case "pi":
|
|
13
|
+
return new pi_js_1.PiAdapter(cwd);
|
|
14
|
+
case "claude":
|
|
15
|
+
return new claude_js_1.ClaudeAdapter(cwd);
|
|
16
|
+
case "opencode":
|
|
17
|
+
return new opencode_js_1.OpenCodeAdapter();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.SUPPORTED_AGENTS = ["pi", "claude", "opencode"];
|
|
21
|
+
/**
|
|
22
|
+
* Auto-detect which agents have a session in the current project. Returns ids
|
|
23
|
+
* in priority order.
|
|
24
|
+
*/
|
|
25
|
+
function detectAgents(cwd = process.cwd()) {
|
|
26
|
+
const found = [];
|
|
27
|
+
if (pi_js_1.PiAdapter.isPresent(cwd))
|
|
28
|
+
found.push("pi");
|
|
29
|
+
if (claude_js_1.ClaudeAdapter.isPresent(cwd))
|
|
30
|
+
found.push("claude");
|
|
31
|
+
if (opencode_js_1.OpenCodeAdapter.isPresent())
|
|
32
|
+
found.push("opencode");
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OpenCodeAdapter = void 0;
|
|
4
|
+
const exec_js_1 = require("../core/exec.js");
|
|
5
|
+
const session_store_js_1 = require("../core/session-store.js");
|
|
6
|
+
/**
|
|
7
|
+
* OpenCode adapter.
|
|
8
|
+
*
|
|
9
|
+
* OpenCode exposes a real session export CLI, so this adapter follows the
|
|
10
|
+
* "use the native CLI" rule strictly:
|
|
11
|
+
* 1. `opencode session list` -> newest session id for this project
|
|
12
|
+
* 2. `opencode export <id>` -> full session as JSON
|
|
13
|
+
*/
|
|
14
|
+
class OpenCodeAdapter {
|
|
15
|
+
static BIN = "opencode";
|
|
16
|
+
getName() {
|
|
17
|
+
return "OpenCode";
|
|
18
|
+
}
|
|
19
|
+
static isPresent() {
|
|
20
|
+
return (0, exec_js_1.commandExists)(OpenCodeAdapter.BIN);
|
|
21
|
+
}
|
|
22
|
+
async extractSession() {
|
|
23
|
+
const sessionId = await this.latestSessionId();
|
|
24
|
+
if (!sessionId) {
|
|
25
|
+
throw new Error("No OpenCode session found for this project.");
|
|
26
|
+
}
|
|
27
|
+
const { stdout, code, stderr } = await (0, exec_js_1.run)(OpenCodeAdapter.BIN, ["export", sessionId], { timeoutMs: 120_000 });
|
|
28
|
+
if (code !== 0) {
|
|
29
|
+
throw new Error(`opencode export failed: ${stderr.trim() || `exit ${code}`}`);
|
|
30
|
+
}
|
|
31
|
+
let parsed;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(stdout);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new Error("Could not parse `opencode export` output as JSON.");
|
|
37
|
+
}
|
|
38
|
+
const messages = [];
|
|
39
|
+
for (const msg of parsed.messages ?? []) {
|
|
40
|
+
const role = msg.info?.role === "assistant" ? "assistant" : "user";
|
|
41
|
+
const text = renderParts(msg.parts ?? []);
|
|
42
|
+
if (text)
|
|
43
|
+
messages.push({ role, content: text });
|
|
44
|
+
}
|
|
45
|
+
return messages;
|
|
46
|
+
}
|
|
47
|
+
async latestSessionId() {
|
|
48
|
+
const { stdout, code, stderr } = await (0, exec_js_1.run)(OpenCodeAdapter.BIN, ["session", "list"], { timeoutMs: 60_000 });
|
|
49
|
+
if (code !== 0) {
|
|
50
|
+
throw new Error(`opencode session list failed: ${stderr.trim() || `exit ${code}`}`);
|
|
51
|
+
}
|
|
52
|
+
// Rows are sorted newest-first; the id is the first `ses_…` token per line.
|
|
53
|
+
for (const line of stdout.split("\n")) {
|
|
54
|
+
const match = /\bses_\S+/.exec(line);
|
|
55
|
+
if (match)
|
|
56
|
+
return match[0];
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
exports.OpenCodeAdapter = OpenCodeAdapter;
|
|
62
|
+
function renderParts(parts) {
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (part.type === "text" && part.text) {
|
|
66
|
+
out.push(part.text);
|
|
67
|
+
}
|
|
68
|
+
else if (part.type === "tool") {
|
|
69
|
+
const name = part.tool ?? "tool";
|
|
70
|
+
out.push(`[tool: ${name}] ${(0, session_store_js_1.summarizeValue)(part.state?.input)}`);
|
|
71
|
+
}
|
|
72
|
+
// reasoning / step-start / step-finish are skipped.
|
|
73
|
+
}
|
|
74
|
+
return out.join("\n").trim();
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PiAdapter = void 0;
|
|
4
|
+
const node_path_1 = require("node:path");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const paths_js_1 = require("../core/paths.js");
|
|
7
|
+
const session_store_js_1 = require("../core/session-store.js");
|
|
8
|
+
/**
|
|
9
|
+
* Pi adapter.
|
|
10
|
+
*
|
|
11
|
+
* Pi has no command that dumps a session as JSON to stdout (`--mode json`
|
|
12
|
+
* starts a *new* model turn). The session itself is a documented JSONL file
|
|
13
|
+
* at ~/.pi/agent/sessions/<slug>/<id>.jsonl, so we read the latest one for the
|
|
14
|
+
* current project directory.
|
|
15
|
+
*/
|
|
16
|
+
class PiAdapter {
|
|
17
|
+
cwd;
|
|
18
|
+
constructor(cwd = process.cwd()) {
|
|
19
|
+
this.cwd = cwd;
|
|
20
|
+
}
|
|
21
|
+
getName() {
|
|
22
|
+
return "Pi";
|
|
23
|
+
}
|
|
24
|
+
static isPresent(cwd = process.cwd()) {
|
|
25
|
+
return (0, node_fs_1.existsSync)((0, node_path_1.join)(paths_js_1.PI_SESSIONS_ROOT, (0, paths_js_1.piSlug)(cwd)));
|
|
26
|
+
}
|
|
27
|
+
async extractSession() {
|
|
28
|
+
const dir = (0, node_path_1.join)(paths_js_1.PI_SESSIONS_ROOT, (0, paths_js_1.piSlug)(this.cwd));
|
|
29
|
+
const file = (0, session_store_js_1.findLatestJsonl)(dir);
|
|
30
|
+
if (!file) {
|
|
31
|
+
throw new Error(`No Pi session found for this project (${dir}).`);
|
|
32
|
+
}
|
|
33
|
+
const messages = [];
|
|
34
|
+
for (const line of (0, session_store_js_1.readJsonl)(file)) {
|
|
35
|
+
const entry = line;
|
|
36
|
+
if (entry.type !== "message" || !entry.message)
|
|
37
|
+
continue;
|
|
38
|
+
const { role, content } = entry.message;
|
|
39
|
+
const text = renderParts(content);
|
|
40
|
+
if (!text)
|
|
41
|
+
continue;
|
|
42
|
+
messages.push({ role: normalizeRole(role), content: text });
|
|
43
|
+
}
|
|
44
|
+
return messages;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.PiAdapter = PiAdapter;
|
|
48
|
+
function normalizeRole(role) {
|
|
49
|
+
if (role === "assistant")
|
|
50
|
+
return "assistant";
|
|
51
|
+
if (role === "user")
|
|
52
|
+
return "user";
|
|
53
|
+
return "system"; // toolResult and anything else
|
|
54
|
+
}
|
|
55
|
+
function renderParts(content) {
|
|
56
|
+
if (!Array.isArray(content))
|
|
57
|
+
return "";
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const part of content) {
|
|
60
|
+
if (part.type === "text" && part.text) {
|
|
61
|
+
out.push(part.text);
|
|
62
|
+
}
|
|
63
|
+
else if (part.type === "toolCall") {
|
|
64
|
+
const name = part.name ?? part.toolName ?? "tool";
|
|
65
|
+
out.push(`[tool: ${name}] ${(0, session_store_js_1.summarizeValue)(part.args ?? part.input)}`);
|
|
66
|
+
}
|
|
67
|
+
// thinking parts are internal — skipped.
|
|
68
|
+
}
|
|
69
|
+
return out.join("\n").trim();
|
|
70
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AgentNotFoundError = void 0;
|
|
4
|
+
class AgentNotFoundError extends Error {
|
|
5
|
+
binary;
|
|
6
|
+
constructor(binary) {
|
|
7
|
+
super(`Agent '${binary}' not found. Is it installed and in your PATH?`);
|
|
8
|
+
this.binary = binary;
|
|
9
|
+
this.name = "AgentNotFoundError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.AgentNotFoundError = AgentNotFoundError;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runExport = runExport;
|
|
37
|
+
const p = __importStar(require("@clack/prompts"));
|
|
38
|
+
const index_js_1 = require("../adapters/index.js");
|
|
39
|
+
const types_js_1 = require("../adapters/types.js");
|
|
40
|
+
const formatter_js_1 = require("../core/formatter.js");
|
|
41
|
+
const crypto_js_1 = require("../core/crypto.js");
|
|
42
|
+
const transport_js_1 = require("../core/transport.js");
|
|
43
|
+
const link_js_1 = require("../core/link.js");
|
|
44
|
+
async function runExport(opts) {
|
|
45
|
+
p.intro("send-context export");
|
|
46
|
+
const workerHost = opts.worker ?? process.env.SEND_CONTEXT_WORKER;
|
|
47
|
+
if (!workerHost) {
|
|
48
|
+
p.cancel("No worker host. Pass --worker <host> or set SEND_CONTEXT_WORKER (e.g. your-project.deno.net).");
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const agentId = await resolveAgent(opts.agent);
|
|
53
|
+
if (!agentId)
|
|
54
|
+
return;
|
|
55
|
+
const adapter = (0, index_js_1.createAdapter)(agentId);
|
|
56
|
+
const spin = p.spinner();
|
|
57
|
+
spin.start(`Extracting session from ${adapter.getName()}`);
|
|
58
|
+
let messages;
|
|
59
|
+
try {
|
|
60
|
+
messages = await adapter.extractSession();
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
spin.stop("Extraction failed.");
|
|
64
|
+
p.cancel(err instanceof types_js_1.AgentNotFoundError ? err.message : String(err.message));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
spin.stop(`Extracted ${messages.length} messages.`);
|
|
69
|
+
if (messages.length === 0) {
|
|
70
|
+
p.cancel("Session has no messages to hand off.");
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const sections = await promptSections();
|
|
75
|
+
if (sections === null)
|
|
76
|
+
return;
|
|
77
|
+
const appendix = await curateAppendix(messages);
|
|
78
|
+
if (appendix === null)
|
|
79
|
+
return;
|
|
80
|
+
const markdown = (0, formatter_js_1.formatToHandoffSkill)({
|
|
81
|
+
sourceAgent: adapter.getName(),
|
|
82
|
+
timestamp: new Date().toISOString(),
|
|
83
|
+
allMessages: messages,
|
|
84
|
+
appendix,
|
|
85
|
+
sections,
|
|
86
|
+
});
|
|
87
|
+
const password = await p.password({
|
|
88
|
+
message: "Set a password (the receiver needs it to decrypt):",
|
|
89
|
+
validate: (v) => (v.length < 4 ? "Use at least 4 characters." : undefined),
|
|
90
|
+
});
|
|
91
|
+
if (p.isCancel(password)) {
|
|
92
|
+
cancelled();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const payload = (0, crypto_js_1.encrypt)(markdown, password);
|
|
96
|
+
spin.start("Uploading encrypted handoff");
|
|
97
|
+
let id;
|
|
98
|
+
try {
|
|
99
|
+
id = await (0, transport_js_1.uploadPayload)(workerHost, payload);
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
spin.stop("Upload failed.");
|
|
103
|
+
p.cancel(String(err.message));
|
|
104
|
+
process.exitCode = 1;
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
spin.stop("Uploaded.");
|
|
108
|
+
const link = (0, link_js_1.encodeLink)({ workerHost, id, password });
|
|
109
|
+
p.note(link, "Share this link (expires in 24h)");
|
|
110
|
+
p.outro("Done.");
|
|
111
|
+
}
|
|
112
|
+
async function resolveAgent(preset) {
|
|
113
|
+
if (preset) {
|
|
114
|
+
if (!index_js_1.SUPPORTED_AGENTS.includes(preset)) {
|
|
115
|
+
p.cancel(`Unknown agent '${preset}'. Supported: ${index_js_1.SUPPORTED_AGENTS.join(", ")}.`);
|
|
116
|
+
process.exitCode = 1;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return preset;
|
|
120
|
+
}
|
|
121
|
+
const detected = (0, index_js_1.detectAgents)();
|
|
122
|
+
if (detected.length === 0) {
|
|
123
|
+
p.cancel("No agent session detected here. Use --agent <pi|claude|opencode>.");
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
if (detected.length === 1)
|
|
128
|
+
return detected[0];
|
|
129
|
+
const choice = await p.select({
|
|
130
|
+
message: "Which agent session to hand off?",
|
|
131
|
+
options: detected.map((id) => ({ value: id, label: id })),
|
|
132
|
+
});
|
|
133
|
+
if (p.isCancel(choice)) {
|
|
134
|
+
cancelled();
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
return choice;
|
|
138
|
+
}
|
|
139
|
+
async function promptSections() {
|
|
140
|
+
const wants = await p.confirm({
|
|
141
|
+
message: "Add a written summary (objective, blockers, next steps)? Recommended.",
|
|
142
|
+
initialValue: true,
|
|
143
|
+
});
|
|
144
|
+
if (p.isCancel(wants)) {
|
|
145
|
+
cancelled();
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (!wants)
|
|
149
|
+
return {};
|
|
150
|
+
const fields = [
|
|
151
|
+
["objective", "Primary objective"],
|
|
152
|
+
["currentState", "Current state & blockers"],
|
|
153
|
+
["completedSteps", "Completed steps (one per line)"],
|
|
154
|
+
["failedApproaches", "Failed approaches — do not retry"],
|
|
155
|
+
["nextSteps", "Next steps for the receiver"],
|
|
156
|
+
];
|
|
157
|
+
const sections = {};
|
|
158
|
+
for (const [key, label] of fields) {
|
|
159
|
+
const value = await p.text({ message: label, placeholder: "(optional, Enter to skip)" });
|
|
160
|
+
if (p.isCancel(value)) {
|
|
161
|
+
cancelled();
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (value.trim())
|
|
165
|
+
sections[key] = value.trim();
|
|
166
|
+
}
|
|
167
|
+
return sections;
|
|
168
|
+
}
|
|
169
|
+
async function curateAppendix(messages) {
|
|
170
|
+
// Default to selecting the most recent 10 messages.
|
|
171
|
+
const recentFrom = Math.max(0, messages.length - 10);
|
|
172
|
+
const options = messages.map((m, i) => ({
|
|
173
|
+
value: i,
|
|
174
|
+
label: `[${m.role}] ${preview(m.content)}`,
|
|
175
|
+
}));
|
|
176
|
+
const selected = await p.multiselect({
|
|
177
|
+
message: "Select messages for the Raw Context Appendix (space to toggle):",
|
|
178
|
+
options,
|
|
179
|
+
initialValues: options.slice(recentFrom).map((o) => o.value),
|
|
180
|
+
required: false,
|
|
181
|
+
});
|
|
182
|
+
if (p.isCancel(selected)) {
|
|
183
|
+
cancelled();
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return selected.map((i) => messages[i]);
|
|
187
|
+
}
|
|
188
|
+
function preview(content) {
|
|
189
|
+
const line = content.replace(/\s+/g, " ").trim();
|
|
190
|
+
return line.length > 80 ? `${line.slice(0, 80)}…` : line;
|
|
191
|
+
}
|
|
192
|
+
function cancelled() {
|
|
193
|
+
p.cancel("Cancelled.");
|
|
194
|
+
process.exitCode = 130;
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runReceive = runReceive;
|
|
37
|
+
const node_child_process_1 = require("node:child_process");
|
|
38
|
+
const p = __importStar(require("@clack/prompts"));
|
|
39
|
+
const link_js_1 = require("../core/link.js");
|
|
40
|
+
const transport_js_1 = require("../core/transport.js");
|
|
41
|
+
const crypto_js_1 = require("../core/crypto.js");
|
|
42
|
+
const INJECTION_PREAMBLE = `SYSTEM CONTEXT INJECTION:
|
|
43
|
+
You are resuming a task. You have been provided with an Context Handoff Document.
|
|
44
|
+
Read it carefully. Do not repeat "Completed Steps". Avoid "Failed Approaches".
|
|
45
|
+
Acknowledge the "Current State" and immediately begin working on the "Next Steps".`;
|
|
46
|
+
async function runReceive(rawLink, agentArgv) {
|
|
47
|
+
p.intro("send-context receive");
|
|
48
|
+
// commander hands us the literal "--" separator as the first token; drop it.
|
|
49
|
+
if (agentArgv[0] === "--")
|
|
50
|
+
agentArgv = agentArgv.slice(1);
|
|
51
|
+
let link;
|
|
52
|
+
try {
|
|
53
|
+
link = (0, link_js_1.decodeLink)(rawLink);
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
p.cancel(String(err.message));
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const spin = p.spinner();
|
|
61
|
+
spin.start("Downloading handoff");
|
|
62
|
+
let payload;
|
|
63
|
+
try {
|
|
64
|
+
payload = await (0, transport_js_1.downloadPayload)(link.workerHost, link.id);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
spin.stop("Download failed.");
|
|
68
|
+
const msg = err.message;
|
|
69
|
+
p.cancel(msg === "LINK_EXPIRED" ? "Link expired or invalid." : msg);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
spin.stop("Downloaded.");
|
|
74
|
+
let password = link.password;
|
|
75
|
+
if (!password) {
|
|
76
|
+
const entered = await p.password({ message: "Password:" });
|
|
77
|
+
if (p.isCancel(entered)) {
|
|
78
|
+
p.cancel("Cancelled.");
|
|
79
|
+
process.exitCode = 130;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
password = entered;
|
|
83
|
+
}
|
|
84
|
+
let markdown;
|
|
85
|
+
try {
|
|
86
|
+
markdown = (0, crypto_js_1.decrypt)(payload, password);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
const msg = err.message;
|
|
90
|
+
p.cancel(msg === "INVALID_PASSWORD" ? "Invalid password." : msg);
|
|
91
|
+
process.exitCode = 1;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (agentArgv.length === 0) {
|
|
95
|
+
// No agent to launch — just print the handoff document.
|
|
96
|
+
p.outro("No agent command given; printing handoff document below.");
|
|
97
|
+
process.stdout.write(`\n${markdown}\n`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const [bin, ...rest] = agentArgv;
|
|
101
|
+
const userRequest = extractUserRequest(rest);
|
|
102
|
+
const injection = `${INJECTION_PREAMBLE}\n\n${markdown}\n\nUSER REQUEST:\n${userRequest}`;
|
|
103
|
+
p.outro(`Launching ${bin} with injected context…`);
|
|
104
|
+
await launchAgent(bin, rest, injection);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The trailing free-text in the agent args is treated as the user's request so
|
|
108
|
+
* it can be merged into the injection prompt. We pass the whole thing as the
|
|
109
|
+
* final positional argument to the agent CLI (the wrapper pattern), so the
|
|
110
|
+
* receiver interacts with their agent normally.
|
|
111
|
+
*/
|
|
112
|
+
function extractUserRequest(rest) {
|
|
113
|
+
const positional = rest.filter((a) => !a.startsWith("-"));
|
|
114
|
+
return positional.join(" ") || "Continue the work described above.";
|
|
115
|
+
}
|
|
116
|
+
function launchAgent(bin, rest, injection) {
|
|
117
|
+
// Replace the trailing positional prompt (if any) with the full injection;
|
|
118
|
+
// keep any flags the user passed before it.
|
|
119
|
+
const flags = rest.filter((a) => a.startsWith("-"));
|
|
120
|
+
const args = [...flags, injection];
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const child = (0, node_child_process_1.spawn)(bin, args, { stdio: "inherit" });
|
|
123
|
+
child.on("error", (err) => {
|
|
124
|
+
if (err.code === "ENOENT") {
|
|
125
|
+
process.stderr.write(`\nAgent '${bin}' not found. Is it installed and in your PATH?\n`);
|
|
126
|
+
process.exitCode = 127;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
process.stderr.write(`\nFailed to launch '${bin}': ${err.message}\n`);
|
|
130
|
+
process.exitCode = 1;
|
|
131
|
+
}
|
|
132
|
+
resolve();
|
|
133
|
+
});
|
|
134
|
+
child.on("close", (code) => {
|
|
135
|
+
process.exitCode = code ?? 0;
|
|
136
|
+
resolve();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encrypt = encrypt;
|
|
4
|
+
exports.decrypt = decrypt;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
/**
|
|
7
|
+
* Zero-knowledge encryption helpers built on Node's native crypto.
|
|
8
|
+
*
|
|
9
|
+
* Scheme: AES-256-GCM. The key is derived from the password with scrypt
|
|
10
|
+
* over a per-payload random salt. The 16-byte GCM auth tag is appended to
|
|
11
|
+
* the ciphertext, so a wrong password fails authentication on decrypt.
|
|
12
|
+
*/
|
|
13
|
+
const ALGORITHM = "aes-256-gcm";
|
|
14
|
+
const KEY_LEN = 32; // 256-bit
|
|
15
|
+
const IV_LEN = 12; // GCM standard nonce length
|
|
16
|
+
const SALT_LEN = 16;
|
|
17
|
+
const TAG_LEN = 16;
|
|
18
|
+
function deriveKey(password, salt) {
|
|
19
|
+
return (0, node_crypto_1.scryptSync)(password, salt, KEY_LEN);
|
|
20
|
+
}
|
|
21
|
+
function encrypt(plaintext, password) {
|
|
22
|
+
const salt = (0, node_crypto_1.randomBytes)(SALT_LEN);
|
|
23
|
+
const iv = (0, node_crypto_1.randomBytes)(IV_LEN);
|
|
24
|
+
const key = deriveKey(password, salt);
|
|
25
|
+
const cipher = (0, node_crypto_1.createCipheriv)(ALGORITHM, key, iv);
|
|
26
|
+
const encrypted = Buffer.concat([
|
|
27
|
+
cipher.update(plaintext, "utf8"),
|
|
28
|
+
cipher.final(),
|
|
29
|
+
]);
|
|
30
|
+
const tag = cipher.getAuthTag();
|
|
31
|
+
return {
|
|
32
|
+
salt: salt.toString("base64"),
|
|
33
|
+
iv: iv.toString("base64"),
|
|
34
|
+
ciphertext: Buffer.concat([encrypted, tag]).toString("base64"),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function decrypt(payload, password) {
|
|
38
|
+
const salt = Buffer.from(payload.salt, "base64");
|
|
39
|
+
const iv = Buffer.from(payload.iv, "base64");
|
|
40
|
+
const data = Buffer.from(payload.ciphertext, "base64");
|
|
41
|
+
if (data.length < TAG_LEN) {
|
|
42
|
+
throw new Error("Ciphertext too short — payload is corrupt.");
|
|
43
|
+
}
|
|
44
|
+
const tag = data.subarray(data.length - TAG_LEN);
|
|
45
|
+
const encrypted = data.subarray(0, data.length - TAG_LEN);
|
|
46
|
+
const key = deriveKey(password, salt);
|
|
47
|
+
const decipher = (0, node_crypto_1.createDecipheriv)(ALGORITHM, key, iv);
|
|
48
|
+
decipher.setAuthTag(tag);
|
|
49
|
+
try {
|
|
50
|
+
return Buffer.concat([
|
|
51
|
+
decipher.update(encrypted),
|
|
52
|
+
decipher.final(),
|
|
53
|
+
]).toString("utf8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// GCM auth tag mismatch — wrong password or tampered payload.
|
|
57
|
+
throw new Error("INVALID_PASSWORD");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.commandExists = commandExists;
|
|
4
|
+
exports.run = run;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const types_js_1 = require("../adapters/types.js");
|
|
7
|
+
/** Cheap, synchronous check that a binary is resolvable on PATH. */
|
|
8
|
+
function commandExists(binary) {
|
|
9
|
+
const res = (0, node_child_process_1.spawnSync)(binary, ["--version"], { stdio: "ignore" });
|
|
10
|
+
return !res.error;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Run a binary and capture stdout/stderr. Rejects with AgentNotFoundError if
|
|
14
|
+
* the binary is missing (ENOENT). Never uses a shell, so arguments are passed
|
|
15
|
+
* safely without interpolation.
|
|
16
|
+
*/
|
|
17
|
+
function run(binary, args, opts = {}) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const child = (0, node_child_process_1.spawn)(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
20
|
+
let stdout = "";
|
|
21
|
+
let stderr = "";
|
|
22
|
+
let settled = false;
|
|
23
|
+
const timer = opts.timeoutMs
|
|
24
|
+
? setTimeout(() => {
|
|
25
|
+
if (settled)
|
|
26
|
+
return;
|
|
27
|
+
settled = true;
|
|
28
|
+
child.kill("SIGKILL");
|
|
29
|
+
reject(new Error(`'${binary}' timed out after ${opts.timeoutMs}ms.`));
|
|
30
|
+
}, opts.timeoutMs)
|
|
31
|
+
: null;
|
|
32
|
+
child.on("error", (err) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
settled = true;
|
|
36
|
+
if (timer)
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
if (err.code === "ENOENT") {
|
|
39
|
+
reject(new types_js_1.AgentNotFoundError(binary));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
reject(err);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
child.stdout.on("data", (d) => (stdout += d.toString()));
|
|
46
|
+
child.stderr.on("data", (d) => (stderr += d.toString()));
|
|
47
|
+
child.on("close", (code) => {
|
|
48
|
+
if (settled)
|
|
49
|
+
return;
|
|
50
|
+
settled = true;
|
|
51
|
+
if (timer)
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
resolve({ stdout, stderr, code });
|
|
54
|
+
});
|
|
55
|
+
if (opts.input !== undefined) {
|
|
56
|
+
child.stdin.write(opts.input);
|
|
57
|
+
}
|
|
58
|
+
child.stdin.end();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatToHandoffSkill = formatToHandoffSkill;
|
|
4
|
+
const NOT_SPECIFIED = "_Not specified by sender._";
|
|
5
|
+
function formatToHandoffSkill(input) {
|
|
6
|
+
const { sourceAgent, timestamp, appendix, allMessages, sections = {} } = input;
|
|
7
|
+
const firstUser = allMessages.find((m) => m.role === "user")?.content ?? "";
|
|
8
|
+
const originalTask = firstLine(firstUser) || NOT_SPECIFIED;
|
|
9
|
+
const objective = sections.objective?.trim() || originalTask;
|
|
10
|
+
const currentState = sections.currentState?.trim() || NOT_SPECIFIED;
|
|
11
|
+
const completedSteps = sections.completedSteps?.trim() || NOT_SPECIFIED;
|
|
12
|
+
const failedApproaches = sections.failedApproaches?.trim() || NOT_SPECIFIED;
|
|
13
|
+
const nextSteps = sections.nextSteps?.trim() || NOT_SPECIFIED;
|
|
14
|
+
return [
|
|
15
|
+
`# Context Handoff Document`,
|
|
16
|
+
`**Source Agent:** ${sourceAgent}`,
|
|
17
|
+
`**Timestamp:** ${timestamp}`,
|
|
18
|
+
`**Original Task:** ${originalTask}`,
|
|
19
|
+
``,
|
|
20
|
+
`## 1. Primary Objective`,
|
|
21
|
+
objective,
|
|
22
|
+
``,
|
|
23
|
+
`## 2. Current State & Blockers`,
|
|
24
|
+
currentState,
|
|
25
|
+
``,
|
|
26
|
+
`## 3. Completed Steps`,
|
|
27
|
+
completedSteps,
|
|
28
|
+
``,
|
|
29
|
+
`## 4. Failed Approaches (Do Not Retry)`,
|
|
30
|
+
failedApproaches,
|
|
31
|
+
``,
|
|
32
|
+
`## 5. Next Steps`,
|
|
33
|
+
nextSteps,
|
|
34
|
+
``,
|
|
35
|
+
`## 6. Raw Context Appendix`,
|
|
36
|
+
renderAppendix(appendix),
|
|
37
|
+
``,
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
function renderAppendix(messages) {
|
|
41
|
+
if (messages.length === 0)
|
|
42
|
+
return "_No raw context included._";
|
|
43
|
+
return messages
|
|
44
|
+
.map((m) => {
|
|
45
|
+
const label = m.role.toUpperCase();
|
|
46
|
+
return `### ${label}\n\n${m.content.trim()}`;
|
|
47
|
+
})
|
|
48
|
+
.join("\n\n---\n\n");
|
|
49
|
+
}
|
|
50
|
+
function firstLine(text) {
|
|
51
|
+
const line = text
|
|
52
|
+
.split("\n")
|
|
53
|
+
.map((l) => l.trim())
|
|
54
|
+
.find((l) => l.length > 0);
|
|
55
|
+
if (!line)
|
|
56
|
+
return "";
|
|
57
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
58
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* send-context link codec.
|
|
4
|
+
*
|
|
5
|
+
* Format: send-context://<workerHost>/<id>#<password>
|
|
6
|
+
*
|
|
7
|
+
* - workerHost: the worker host (no scheme), e.g.
|
|
8
|
+
* "send-context.example.deno.net". https is always assumed.
|
|
9
|
+
* - id: the KV record id returned by the worker on upload.
|
|
10
|
+
* - password: the symmetric password in the URL fragment. The fragment
|
|
11
|
+
* never leaves the local machine when fetching (it is client-side only),
|
|
12
|
+
* keeping the transport zero-knowledge.
|
|
13
|
+
*/
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.encodeLink = encodeLink;
|
|
16
|
+
exports.decodeLink = decodeLink;
|
|
17
|
+
exports.workerBaseUrl = workerBaseUrl;
|
|
18
|
+
const LINK_RE = /^send-context:\/\/([^/]+)\/([^#]+)(?:#(.*))?$/;
|
|
19
|
+
function encodeLink(link) {
|
|
20
|
+
const { workerHost, id, password } = link;
|
|
21
|
+
if (!workerHost || !id || !password) {
|
|
22
|
+
throw new Error("encodeLink: workerHost, id and password are all required.");
|
|
23
|
+
}
|
|
24
|
+
return `send-context://${stripScheme(workerHost)}/${encodeURIComponent(id)}#${encodeURIComponent(password)}`;
|
|
25
|
+
}
|
|
26
|
+
function decodeLink(raw) {
|
|
27
|
+
const match = LINK_RE.exec(raw.trim());
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new Error("Invalid send-context link. Expected: send-context://<host>/<id>#<password>");
|
|
30
|
+
}
|
|
31
|
+
const [, workerHost, id, password] = match;
|
|
32
|
+
return {
|
|
33
|
+
workerHost: stripScheme(workerHost),
|
|
34
|
+
id: decodeURIComponent(id),
|
|
35
|
+
password: password ? decodeURIComponent(password) : "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Build the https base URL for the worker from a stored host. */
|
|
39
|
+
function workerBaseUrl(workerHost) {
|
|
40
|
+
return `https://${stripScheme(workerHost)}`;
|
|
41
|
+
}
|
|
42
|
+
function stripScheme(host) {
|
|
43
|
+
return host.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
44
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLAUDE_PROJECTS_ROOT = exports.PI_SESSIONS_ROOT = void 0;
|
|
4
|
+
exports.piSlug = piSlug;
|
|
5
|
+
exports.claudeSlug = claudeSlug;
|
|
6
|
+
const node_os_1 = require("node:os");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
/**
|
|
9
|
+
* Centralized platform paths for agent session stores. Keeping these in one
|
|
10
|
+
* place means a new agent layout only changes here.
|
|
11
|
+
*/
|
|
12
|
+
exports.PI_SESSIONS_ROOT = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent", "sessions");
|
|
13
|
+
exports.CLAUDE_PROJECTS_ROOT = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "projects");
|
|
14
|
+
/**
|
|
15
|
+
* pi encodes the project directory as a session-store folder name:
|
|
16
|
+
* /home/x/Documents/proj -> --home-x-Documents-proj--
|
|
17
|
+
* (slashes become dashes, the result is wrapped in leading/trailing dashes).
|
|
18
|
+
*/
|
|
19
|
+
function piSlug(cwd) {
|
|
20
|
+
return `-${cwd.replace(/\//g, "-")}--`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Claude Code encodes the project directory as a project folder name by
|
|
24
|
+
* replacing every non-alphanumeric character with a dash:
|
|
25
|
+
* /home/x/Documents/proj -> -home-x-Documents-proj
|
|
26
|
+
*/
|
|
27
|
+
function claudeSlug(cwd) {
|
|
28
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
29
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findLatestJsonl = findLatestJsonl;
|
|
4
|
+
exports.readJsonl = readJsonl;
|
|
5
|
+
exports.summarizeValue = summarizeValue;
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
/** Return the newest *.jsonl file in a directory, or null if none/absent. */
|
|
9
|
+
function findLatestJsonl(dir) {
|
|
10
|
+
if (!(0, node_fs_1.existsSync)(dir))
|
|
11
|
+
return null;
|
|
12
|
+
let newest = null;
|
|
13
|
+
for (const name of (0, node_fs_1.readdirSync)(dir)) {
|
|
14
|
+
if (!name.endsWith(".jsonl"))
|
|
15
|
+
continue;
|
|
16
|
+
const path = (0, node_path_1.join)(dir, name);
|
|
17
|
+
const mtime = (0, node_fs_1.statSync)(path).mtimeMs;
|
|
18
|
+
if (!newest || mtime > newest.mtime)
|
|
19
|
+
newest = { path, mtime };
|
|
20
|
+
}
|
|
21
|
+
return newest?.path ?? null;
|
|
22
|
+
}
|
|
23
|
+
/** Parse a JSON-lines file into objects, skipping blank/malformed lines. */
|
|
24
|
+
function readJsonl(path) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const line of (0, node_fs_1.readFileSync)(path, "utf8").split("\n")) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
out.push(JSON.parse(trimmed));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
/* skip partial/corrupt line */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** Truncate long tool payloads so the appendix stays readable. */
|
|
40
|
+
function summarizeValue(value, max = 600) {
|
|
41
|
+
if (value === undefined || value === null)
|
|
42
|
+
return "";
|
|
43
|
+
const str = typeof value === "string" ? value : JSON.stringify(value);
|
|
44
|
+
return str.length > max ? `${str.slice(0, max)}… [truncated]` : str;
|
|
45
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadPayload = uploadPayload;
|
|
4
|
+
exports.downloadPayload = downloadPayload;
|
|
5
|
+
const link_js_1 = require("./link.js");
|
|
6
|
+
/** Upload an encrypted payload to the worker. Returns the stored record id. */
|
|
7
|
+
async function uploadPayload(workerHost, payload) {
|
|
8
|
+
const res = await fetch(`${(0, link_js_1.workerBaseUrl)(workerHost)}/upload`, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json" },
|
|
11
|
+
body: JSON.stringify(payload),
|
|
12
|
+
});
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const serverMsg = await readError(res);
|
|
15
|
+
throw new Error(serverMsg ?? `Upload failed (HTTP ${res.status}).`);
|
|
16
|
+
}
|
|
17
|
+
const data = (await res.json());
|
|
18
|
+
if (!data.id)
|
|
19
|
+
throw new Error("Worker did not return an id.");
|
|
20
|
+
return data.id;
|
|
21
|
+
}
|
|
22
|
+
/** Download an encrypted payload by id. Throws LINK_EXPIRED on 404. */
|
|
23
|
+
async function downloadPayload(workerHost, id) {
|
|
24
|
+
const res = await fetch(`${(0, link_js_1.workerBaseUrl)(workerHost)}/download/${encodeURIComponent(id)}`);
|
|
25
|
+
if (res.status === 404) {
|
|
26
|
+
throw new Error("LINK_EXPIRED");
|
|
27
|
+
}
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(`Download failed (HTTP ${res.status}).`);
|
|
30
|
+
}
|
|
31
|
+
return (await res.json());
|
|
32
|
+
}
|
|
33
|
+
/** Best-effort extraction of a JSON `{error}` message from a failed response. */
|
|
34
|
+
async function readError(res) {
|
|
35
|
+
try {
|
|
36
|
+
const data = (await res.json());
|
|
37
|
+
return data.error ?? null;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const export_js_1 = require("./commands/export.js");
|
|
6
|
+
const receive_js_1 = require("./commands/receive.js");
|
|
7
|
+
const program = new commander_1.Command();
|
|
8
|
+
program
|
|
9
|
+
.name("send-context")
|
|
10
|
+
.description("Relay AI coding-agent session context between developers via an encrypted, ephemeral link.")
|
|
11
|
+
.version("0.1.0")
|
|
12
|
+
.enablePositionalOptions();
|
|
13
|
+
program
|
|
14
|
+
.command("export")
|
|
15
|
+
.description("Extract the current agent session, format it, encrypt it, and produce a share link.")
|
|
16
|
+
.option("-a, --agent <agent>", "force agent: pi | claude | opencode")
|
|
17
|
+
.option("-w, --worker <host>", "Cloudflare Worker host (or set SEND_CONTEXT_WORKER)")
|
|
18
|
+
.action((opts) => (0, export_js_1.runExport)({ agent: opts.agent, worker: opts.worker }));
|
|
19
|
+
program
|
|
20
|
+
.command("receive")
|
|
21
|
+
.description("Download and decrypt a context handoff, then launch an agent with the context injected.")
|
|
22
|
+
.argument("<link>", "send-context:// link")
|
|
23
|
+
.argument("[agent...]", "agent command to launch after --, e.g. -- pi \"continue\"")
|
|
24
|
+
.allowUnknownOption()
|
|
25
|
+
.passThroughOptions()
|
|
26
|
+
.action((link, agent) => (0, receive_js_1.runReceive)(link, agent ?? []));
|
|
27
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
28
|
+
process.stderr.write(`\n${err instanceof Error ? err.message : String(err)}\n`);
|
|
29
|
+
process.exitCode = 1;
|
|
30
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "send-context",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agent-agnostic CLI to relay AI coding-agent session context between developers via an encrypted, ephemeral edge link.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"send-context": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"ai",
|
|
12
|
+
"agent",
|
|
13
|
+
"cli",
|
|
14
|
+
"handoff",
|
|
15
|
+
"context",
|
|
16
|
+
"claude-code",
|
|
17
|
+
"opencode",
|
|
18
|
+
"pi",
|
|
19
|
+
"encryption"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/shafiqimtiaz/context-handoff#readme",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/shafiqimtiaz/context-handoff.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/shafiqimtiaz/context-handoff/issues"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"dev": "tsx src/index.ts",
|
|
36
|
+
"start": "node dist/index.js",
|
|
37
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
38
|
+
"prepublishOnly": "npm run build"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@clack/prompts": "^0.7.0",
|
|
45
|
+
"commander": "^12.1.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"tsx": "^4.19.0",
|
|
50
|
+
"typescript": "^5.6.0"
|
|
51
|
+
}
|
|
52
|
+
}
|