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
7 changes: 4 additions & 3 deletions docs/extensions/linked-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ A tool can start a session from the session that it runs in, and send messages b

A linked session is an ordinary session. The session list shows it under the session that started it (the source).

- It works in the source's folder. It does not get a worktree of its own.
- It works in `cwd` when supplied, otherwise the source's folder. It does not get a worktree of its own.
- It starts with a copy of the source's agent settings. It does not get the source's history, plan mode, or budget. The source's budget covers it (see [Limits of a team](#limits-of-a-team)).
- The link (`linkedFrom` in its session entry) gives no access, and owns nothing.

## Start a session

`start(source, { title?, message, model?, thinking? }, key)` makes the session, and sends `message` to it as its first message. It returns `{ session, address }`: the id of the new session, and the source's address for it.
`start(source, { title?, message, model?, thinking?, cwd? }, key)` makes the session, and sends `message` to it as its first message. It returns `{ session, address }`: the id of the new session, and the source's address for it.

- `cwd` selects an existing working folder using the same path rules as the folder picker (including `~`). Prefer an absolute path. The child loads that folder's project context and tools; the source stays in its own folder. A missing path or a file is rejected before a session is created.
- Without `title`, the title is the first line of the message.
- `model` is `provider/modelId`. It must be the source's model, or a model that the owner allows for subagents (Menu → Providers → Models for subagents). Without it, the session uses the source's model.
- `thinking` can be at most the source's level. Without it, the session uses the source's level. Pi Pocket lowers it to the highest level that the model has.
- When a model cannot be used now, Pi Pocket refuses the call before it makes anything.

## Starts from a human page

Since 0.16.0, a [conversation-bound route](routes.md#bind-a-source-conversation) gets `request.linkedSessions.start({ title?, message, model?, thinking? }, key)` and `list()`. These methods act as the authenticated person and bound source, not `host.requesterOf(source)`. They have no source-id or actor parameter. They end with the request, so await each start in the handler.
Since 0.16.0, a [conversation-bound route](routes.md#bind-a-source-conversation) gets `request.linkedSessions.start({ title?, message, model?, thinking?, cwd? }, key)` and `list()`. These methods act as the authenticated person and bound source, not `host.requesterOf(source)`. They have no source-id or actor parameter. They end with the request, so await each start in the handler.

- The same team, source plan, turn, spend, allowed-model, and provider checks apply. No message is added to the source, and its requester is unchanged.
- Explicit thinking must be supported by the chosen model and no higher than the source's level. It is rejected rather than silently lowered. Omitted thinking inherits with the existing model cap policy. Read `request.conversation.model` and `thinkingLevel`, with `host.modelInfo("provider/modelId")`, to show the effective choices.
Expand Down
4 changes: 2 additions & 2 deletions docs/extensions/reference/host.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ Each call acts as the person that Pi works for in `source`, and that person's ch
```ts
start(
source: ConversationId,
request: { title?: string; message: string; model?: string; thinking?: string },
request: { title?: string; message: string; model?: string; thinking?: string; cwd?: string },
key: string,
): Promise<{ session: ConversationId; address: string }>;
```
Expand All @@ -234,7 +234,7 @@ Since 0.14.0 · Experimental

Makes a session linked to `source` and sends it `message` as its first message. It returns the new session's id, and the address that `source` uses for it.

- The new session works in the folder of `source`. It gets a copy of the agent settings of `source`, but not its history.
- The new session works in `cwd` when supplied, otherwise the folder of `source`. `cwd` must name an existing folder and uses the normal folder-picker path rules. It gets a copy of the agent settings of `source`, but not its history.
- Without `title`, the title is the first line of `message`.
- `model` is `provider/modelId`. It must be the model of `source`, or a model that the owner lets subagents use. Without it, the session uses the model of `source`.
- `thinking` is a level no higher than the level of `source`. Without it, the session uses the level of `source`. Pi Pocket lowers it to the highest level that the model has.
Expand Down
20 changes: 16 additions & 4 deletions src/server/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,18 @@ export interface LinkedSession {
*/
export interface LinkedSessionsApi {
/**
* Start a session linked to `source`, in its folder, with a copy of its agent settings but not its history, and send
* it `message` as its first message. `model` is `provider/modelId`; `thinking` a level no higher than the source's.
* Start a session linked to `source`, in `cwd` or its folder, with a copy of its agent settings but not its history,
* and send it `message` as its first message. `model` is `provider/modelId`; `thinking` a level no higher than the source's.
*/
start(
source: ConversationId,
request: { title?: string; message: string; model?: string; thinking?: string },
request: {
title?: string;
message: string;
model?: string;
thinking?: string;
cwd?: string;
},
key: string,
): Promise<{ session: ConversationId; address: string }>;
/** Send `message` to the linked session at address `to`: a follow-up, or with `steer` at its next pause. */
Expand All @@ -64,7 +70,13 @@ export interface HumanLinkedSession extends LinkedSession {

export interface HumanLinkedSessions {
start(
request: { title?: string; message: string; model?: string; thinking?: ModelThinkingLevel },
request: {
title?: string;
message: string;
model?: string;
thinking?: ModelThinkingLevel;
cwd?: string;
},
key: string,
): Promise<{ session: ConversationId; address: string }>;
list(): Promise<HumanLinkedSession[]>;
Expand Down
35 changes: 30 additions & 5 deletions src/server/linked-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,15 +483,27 @@ export class LinkedSessions implements LinkedSessionsApi {
/** One call makes the session and sends its first message; its record makes a repeat do neither again. */
async start(
source: ConversationId,
request: { title?: string; message: string; model?: string; thinking?: string },
request: {
title?: string;
message: string;
model?: string;
thinking?: string;
cwd?: string;
},
key: string,
): Promise<{ session: ConversationId; address: string }> {
return this.#start(source, request, key);
}

async #start(
source: ConversationId,
request: { title?: string; message: string; model?: string; thinking?: string },
request: {
title?: string;
message: string;
model?: string;
thinking?: string;
cwd?: string;
},
key: string,
actor?: RequestActor,
): Promise<{ session: ConversationId; address: string }> {
Expand Down Expand Up @@ -534,7 +546,7 @@ export class LinkedSessions implements LinkedSessionsApi {
const selected = this.#selection(agent, request, true);

approved = {
cwd: agent.cwd ?? app.cwdOf(source),
cwd: this.#directory(source, agent, request.cwd),
model: selected.model,
thinkingLevel: selected.thinking,
};
Expand Down Expand Up @@ -562,6 +574,19 @@ export class LinkedSessions implements LinkedSessionsApi {
return { session, address };
}

/** Use the normal folder-picker validation for an explicit working directory. */
#directory(source: ConversationId, agent: AgentState, cwd?: string): string {
if (cwd === undefined) {
return agent.cwd ?? this.#app.cwdOf(source);
}

if (typeof cwd !== "string") {
throw new HttpError(400, "The working directory must be a folder path.");
}

return this.#app.workspace.checkDirectory(cwd);
}

/** Resolve exactly the requested policy, without network work. Human choices are never silently lowered. */
#selection(agent: AgentState, request: { model?: string; thinking?: string }, strict: boolean) {
const app = this.#app;
Expand Down Expand Up @@ -614,7 +639,7 @@ export class LinkedSessions implements LinkedSessionsApi {
async #create(
source: ConversationId,
user: User,
request: { title: string; model?: string; thinking?: string },
request: { title: string; model?: string; thinking?: string; cwd?: string },
token: string,
actor?: RequestActor,
): Promise<ConversationId> {
Expand Down Expand Up @@ -648,7 +673,7 @@ export class LinkedSessions implements LinkedSessionsApi {
throw new HttpError(400, `The model ${spec} cannot be used now: ${problem}.`);
}

const cwd = agent.cwd ?? app.cwdOf(source);
const cwd = this.#directory(source, agent, request.cwd);
const now = Date.now();

return app.harness.commit(async (tx) => {
Expand Down
21 changes: 21 additions & 0 deletions test/extension-web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,27 @@ test("bound routes use the clicking actor, keep receipts private, and expose aut
);
});

test("a human linked start admits the requested folder and leaves its source in place", async () => {
const id = await newSession(app);
const original = app.cwdOf(id);
const cwd = join(root, "human-specialist-repository");

mkdirSync(cwd);
const body = { message: "Work in another repository", cwd, key: "human-other-folder" };
const first = await call("owner", `/api/x/workspace/start/${id}`, { method: "POST", body });

assert.equal(first.status, 200);
const target = (first.data as { session: ConversationId }).session;

assert.equal(app.cwdOf(target), cwd);
assert.equal((await app.agentState(target))?.cwd, cwd);
assert.equal(app.cwdOf(id), original);
assert.deepEqual(
(await call("owner", `/api/x/workspace/start/${id}`, { method: "POST", body })).data,
first.data,
);
});

/** Pause the network-facing model check, then change policy before it answers. */
async function whileChecking(
t: import("node:test").TestContext,
Expand Down
99 changes: 99 additions & 0 deletions test/linked-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@ import {
cleanUp,
context,
fakeTab,
lastText,
modelTexts,
newSession,
openApp,
owner,
root,
recordCost,
say,
scriptedModel,
until,
} from "./helpers.ts";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { after, before, test } from "node:test";
import type { ConversationId } from "@earendil-works/pi-durable";
import { fauxAssistantMessage, fauxText, fauxToolCall } from "@earendil-works/pi-ai/providers/faux";
import { LinkedSessionCallsDoc, LinkedSessionRelayDoc, TurnsDoc } from "../src/server/docs.ts";

let app: App;
Expand Down Expand Up @@ -55,6 +60,100 @@ const sent = async (id: ConversationId) =>
(text) => text.includes("<linked-session-message>") && !text.includes("echo:"),
).length;

test("linked sessions can work in another folder without moving the source", async () => {
const id = await source();
const original = app.cwdOf(id);
const cwd = join(root, "specialist-repository");

mkdirSync(cwd);
const request = { message: "Work in the specialist repository", cwd };
const { session } = await app.linkedSessions.start(id, request, "other-folder");

assert.equal(app.cwdOf(session), cwd);
assert.equal((await app.agentState(session))?.cwd, cwd);
assert.equal(app.cwdOf(id), original);
assert.equal((await app.linkedSessions.start(id, request, "other-folder")).session, session);
const inherited = await start(id);

assert.equal(app.cwdOf(inherited.session), original);
});

test("the first linked request loads the destination's project instructions and runs tools there", async () => {
const origin = join(root, "context-source");
const destination = join(root, "context-destination");

mkdirSync(origin);
mkdirSync(destination);
writeFileSync(join(origin, "AGENTS.md"), "SOURCE_PROJECT_ONLY: Instructions for the source.");
writeFileSync(
join(destination, "AGENTS.md"),
"DESTINATION_PROJECT_ONLY: Instructions for the destination.",
);
let firstRequest = "";
const model = scriptedModel((request) => {
const { text, role } = lastText(request as never);

if (role === "user" && text.includes("RUN_DIRECTORY_PROBE")) {
firstRequest ||= JSON.stringify(request);

return fauxAssistantMessage([fauxToolCall("bash", { command: "pwd" })], {
stopReason: "toolUse",
});
}

return fauxAssistantMessage([fauxText("Done.")]);
});
const isolated = await openApp(model, join(root, "context-data"));

try {
const source = await newSession(isolated, origin);

await say(isolated, source, "Hello");
const { session } = await isolated.linkedSessions.start(
source,
{ cwd: destination, message: "RUN_DIRECTORY_PROBE" },
"context-and-tools",
);

await until(() => !isolated.isBusy(session), "the directory probe to finish");
assert.match(firstRequest, /DESTINATION_PROJECT_ONLY/);
assert.doesNotMatch(firstRequest, /SOURCE_PROJECT_ONLY/);
const view = await (await isolated.harness.conversation(session, context))!.context(
context,
);
const result = view.messages.find((message) => message.role === "toolResult");

assert(result && result.role === "toolResult", "the first turn ran its shell tool");
assert.equal(result.isError, false);
assert.equal(
result.content
.flatMap((part) => (part.type === "text" ? [part.text] : []))
.join("")
.trim(),
destination,
);
assert.equal(isolated.cwdOf(source), origin);
} finally {
await isolated.close();
}
});

test("an invalid working directory creates no linked session", async () => {
const id = await source();
const file = join(root, "not-a-directory");

writeFileSync(file, "file");
const count = app.sessions().length;

for (const cwd of [join(root, "missing-directory"), file]) {
await assert.rejects(
app.linkedSessions.start(id, { message: "a task", cwd }, crypto.randomUUID()),
/isn't there|not a folder/,
);
assert.equal(app.sessions().length, count);
}
});

test("a repeated start or send makes one session or one message", async () => {
const id = await source();
const { session: target, address } = await app.linkedSessions.start(
Expand Down
20 changes: 16 additions & 4 deletions types/pocket.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,14 +223,20 @@ export interface SafeText {
*/
export interface LinkedSessions {
/**
* Starts a session linked to `source`, in its folder, and sends it `message` as its first message. `model` is
* Starts a session linked to `source`, in `cwd` or the source's folder, and sends it `message` as its first message. `model` is
* `provider/modelId`; `thinking` is a level no higher than the source's.
* @since 0.14.0
* @experimental
*/
start(
source: ConversationId,
request: { title?: string; message: string; model?: string; thinking?: string },
request: {
title?: string;
message: string;
model?: string;
thinking?: string;
cwd?: string;
},
key: string,
): Promise<{ session: ConversationId; address: string }>;
/**
Expand Down Expand Up @@ -307,11 +313,17 @@ export interface HumanLinkedSession extends LinkedSession {
*/
export interface HumanLinkedSessions {
/**
* Starts once for this key, as the clicking person. Explicit thinking must be supported and no higher than source.
* Starts once for this key, as the clicking person. Optional `cwd` selects an existing folder. Explicit thinking must be supported and no higher than source.
* @since 0.16.0
*/
start(
request: { title?: string; message: string; model?: string; thinking?: ModelThinkingLevel },
request: {
title?: string;
message: string;
model?: string;
thinking?: ModelThinkingLevel;
cwd?: string;
},
key: string,
): Promise<{ session: ConversationId; address: string }>;
/**
Expand Down