pi2web 0.2.0 → 0.2.6
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 +209 -18
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +575 -97
- package/dist/auth.js +85 -20
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +221 -0
- package/dist/collab/hub.js +2888 -0
- package/dist/collab/pi-extension.js +494 -0
- package/dist/collab/review-flow.js +459 -0
- package/dist/collab/routes.js +285 -0
- package/dist/collab/schemas.js +281 -0
- package/dist/collab/scoring-flow.js +336 -0
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +918 -0
- package/dist/collab/types.js +137 -0
- package/dist/collab/validate.js +185 -0
- package/dist/drafts.js +50 -0
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +95 -21
- package/dist/main.js +141 -17
- package/dist/mcp.js +132 -0
- package/dist/metadata-store.js +199 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +288 -49
- package/dist/server.js +558 -358
- package/dist/session-namer.js +163 -0
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +196 -40
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +18 -6
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__init__.cpython-312.pyc +0 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__pycache__/__main__.cpython-312.pyc +0 -0
- package/packages/protocol/openapi.yaml +8 -2
- package/packages/protocol/websocket.schema.json +94 -10
- package/web/activity-view.js +121 -0
- package/web/app.js +3770 -404
- package/web/backend-state.js +269 -0
- package/web/collab.html +95 -0
- package/web/collab.js +1080 -0
- package/web/hid-keyboard.js +453 -0
- package/web/index.html +14 -9
- package/web/styles.css +73 -0
- package/ANDROID_APP_FEATURES.md +0 -256
package/dist/auth.js
CHANGED
|
@@ -1,31 +1,96 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
|
5
|
+
const validateToken = (token) => {
|
|
6
|
+
if (!/^[A-Za-z0-9._~-]{16,128}$/.test(token)) {
|
|
7
|
+
throw Object.assign(new Error('Access token must be 16-128 URL-safe characters (letters, digits, dot, underscore, tilde or hyphen)'), { code: 'INVALID_ACCESS_TOKEN' });
|
|
8
|
+
}
|
|
9
|
+
};
|
|
5
10
|
export class AuthStore {
|
|
6
11
|
dir;
|
|
7
12
|
constructor(dir) {
|
|
8
13
|
this.dir = dir;
|
|
9
14
|
}
|
|
10
|
-
get file() {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
get file() {
|
|
16
|
+
return path.join(this.dir, 'auth.json');
|
|
17
|
+
}
|
|
18
|
+
async init() {
|
|
19
|
+
await mkdir(this.dir, { recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
await readFile(this.file);
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const token = randomBytes(32).toString('base64url');
|
|
26
|
+
await this.save({ tokenHash: hash(token), createdAt: new Date().toISOString() });
|
|
27
|
+
return { token };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async save(data) {
|
|
31
|
+
await mkdir(this.dir, { recursive: true });
|
|
32
|
+
await writeFile(this.file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
33
|
+
await chmod(this.file, 0o600);
|
|
34
|
+
}
|
|
35
|
+
async verify(token) {
|
|
36
|
+
try {
|
|
37
|
+
const data = JSON.parse(await readFile(this.file, 'utf8'));
|
|
38
|
+
const actual = Buffer.from(hash(token));
|
|
39
|
+
const expected = Buffer.from(data.tokenHash);
|
|
40
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** Only the pairing code verifies as a full-power token. */
|
|
47
|
+
async role(token) {
|
|
48
|
+
try {
|
|
49
|
+
const data = JSON.parse(await readFile(this.file, 'utf8'));
|
|
50
|
+
const same = (expected) => {
|
|
51
|
+
if (!expected)
|
|
52
|
+
return false;
|
|
53
|
+
const actual = Buffer.from(hash(token)), stored = Buffer.from(expected);
|
|
54
|
+
return actual.length === stored.length && timingSafeEqual(actual, stored);
|
|
55
|
+
};
|
|
56
|
+
if (same(data.tokenHash))
|
|
57
|
+
return 'full';
|
|
58
|
+
if (same(data.voiceTokenHash))
|
|
59
|
+
return 'voice';
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* absent credential */
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
14
65
|
}
|
|
15
|
-
|
|
66
|
+
async rotateVoice() {
|
|
67
|
+
const old = JSON.parse(await readFile(this.file, 'utf8'));
|
|
16
68
|
const token = randomBytes(32).toString('base64url');
|
|
17
|
-
await this.save({
|
|
18
|
-
return
|
|
19
|
-
}
|
|
20
|
-
async
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
69
|
+
await this.save({ ...old, voiceTokenHash: hash(token), voiceTokenCreatedAt: new Date().toISOString() });
|
|
70
|
+
return token;
|
|
71
|
+
}
|
|
72
|
+
async revokeVoice() {
|
|
73
|
+
const old = JSON.parse(await readFile(this.file, 'utf8'));
|
|
74
|
+
delete old.voiceTokenHash;
|
|
75
|
+
delete old.voiceTokenCreatedAt;
|
|
76
|
+
await this.save(old);
|
|
77
|
+
}
|
|
78
|
+
async rotate() {
|
|
79
|
+
const token = randomBytes(32).toString('base64url');
|
|
80
|
+
const old = JSON.parse(await readFile(this.file, 'utf8'));
|
|
81
|
+
await this.save({ ...old, tokenHash: hash(token), rotatedAt: new Date().toISOString() });
|
|
82
|
+
return token;
|
|
83
|
+
}
|
|
84
|
+
async setToken(token) {
|
|
85
|
+
validateToken(token);
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
data = JSON.parse(await readFile(this.file, 'utf8'));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
await this.save({ tokenHash: hash(token), createdAt: new Date().toISOString() });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await this.save({ ...data, tokenHash: hash(token), rotatedAt: new Date().toISOString() });
|
|
95
|
+
}
|
|
31
96
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
const run = promisify(execFile);
|
|
7
|
+
/** Anchors every round to an immutable code state so round N+1 verdicts are not made against round N's memory. */
|
|
8
|
+
export const gitBaseline = async ({ cwd, subject }) => {
|
|
9
|
+
const git = async (args) => (await run('git', args, { cwd })).stdout;
|
|
10
|
+
try {
|
|
11
|
+
const commit = (await git(['rev-parse', 'HEAD'])).trim();
|
|
12
|
+
let dirtyHash;
|
|
13
|
+
const status = await git(['status', '--porcelain', '-z']);
|
|
14
|
+
if (status) {
|
|
15
|
+
const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z']))
|
|
16
|
+
.split('\0')
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.sort();
|
|
19
|
+
// Hashed as a stream, never buffered. A working tree diff can be tens of megabytes, and a diff dropped for
|
|
20
|
+
// exceeding a buffer would leave a hash of nothing but file names - identical before and after an edit to
|
|
21
|
+
// those same files, which is exactly what `assertBaselineCurrent()` exists to catch.
|
|
22
|
+
const hash = createHash('sha256').update(status);
|
|
23
|
+
await hashStream(hash, spawn('git', ['diff', 'HEAD', '--binary'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
24
|
+
for (const relative of untracked) {
|
|
25
|
+
hash.update(relative).update('\0');
|
|
26
|
+
// A file removed during capture is already represented by status.
|
|
27
|
+
await hashStream(hash, createReadStream(path.join(cwd, relative))).catch(() => { });
|
|
28
|
+
}
|
|
29
|
+
dirtyHash = `sha256:${hash.digest('hex')}`;
|
|
30
|
+
}
|
|
31
|
+
// A commit range such as `main...feature` is only a name: either endpoint can move while HEAD and the
|
|
32
|
+
// working tree stay identical, which would silently change the code under review. Resolve both endpoints
|
|
33
|
+
// so the pinned identity covers the range, not just its spelling.
|
|
34
|
+
let rangeResolved;
|
|
35
|
+
if (subject.type === 'commit_range') {
|
|
36
|
+
const expression = subject.value.trim();
|
|
37
|
+
const endpoints = expression.includes('...')
|
|
38
|
+
? expression.split('...')
|
|
39
|
+
: expression.includes('..')
|
|
40
|
+
? expression.split('..')
|
|
41
|
+
: [expression];
|
|
42
|
+
const resolved = [];
|
|
43
|
+
for (const endpoint of endpoints) {
|
|
44
|
+
const name = endpoint.trim();
|
|
45
|
+
resolved.push(name
|
|
46
|
+
? (await git(['rev-parse', '--verify', `${name}^{commit}`]).catch(() => '')).trim() ||
|
|
47
|
+
`unresolved:${name}`
|
|
48
|
+
: 'HEAD');
|
|
49
|
+
}
|
|
50
|
+
rangeResolved = resolved.join('...');
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
vcs: 'git',
|
|
54
|
+
commit,
|
|
55
|
+
range: subject.type === 'commit_range' ? subject.value : undefined,
|
|
56
|
+
rangeResolved,
|
|
57
|
+
dirtyHash,
|
|
58
|
+
paths: subject.type === 'paths'
|
|
59
|
+
? subject.value
|
|
60
|
+
.split(/[\n,]/)
|
|
61
|
+
.map(value => value.trim())
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
: []
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Not a git checkout (or git is missing). The hub still works; it just cannot prove code identity.
|
|
68
|
+
return { vcs: 'none', paths: [] };
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/** Feeds one stream into a hash, rejecting on failure so a partial read can never pass as "nothing changed". */
|
|
72
|
+
function hashStream(hash, source) {
|
|
73
|
+
const process = 'stdout' in source ? source : undefined, stream = (process ? process.stdout : source);
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
if (!stream)
|
|
76
|
+
return reject(new Error('Stream is not readable'));
|
|
77
|
+
stream.on('data', chunk => hash.update(chunk));
|
|
78
|
+
stream.on('error', reject);
|
|
79
|
+
if (!process)
|
|
80
|
+
return void stream.on('end', resolve);
|
|
81
|
+
process.on('error', reject);
|
|
82
|
+
process.on('close', code => (code === 0 ? resolve() : reject(new Error(`git exited with ${code}`))));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
const BUSY_STATES = ['starting', 'streaming', 'waiting_for_user', 'stopping'];
|
|
2
|
+
/** How long after a session finished a still-undelivered closing note is worth retrying. */
|
|
3
|
+
const CLOSING_NOTE_RETRY_WINDOW_MS = 24 * 3600_000;
|
|
4
|
+
export class CollabDispatcher {
|
|
5
|
+
hub;
|
|
6
|
+
deps;
|
|
7
|
+
unsubscribe;
|
|
8
|
+
pending = new Map();
|
|
9
|
+
inFlight = new Map();
|
|
10
|
+
/** Last delivered task key per participant, so a re-emitted assignment does not spam the agent. */
|
|
11
|
+
delivered = new Map();
|
|
12
|
+
closed = false;
|
|
13
|
+
constructor(hub, deps) {
|
|
14
|
+
this.hub = hub;
|
|
15
|
+
this.deps = deps;
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
if (this.unsubscribe || this.closed)
|
|
19
|
+
return;
|
|
20
|
+
this.unsubscribe = this.hub.subscribe(event => this.onEvent(event));
|
|
21
|
+
this.resumePending();
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Wake-ups are triggered by events, and events are not replayed after a restart. Without this,
|
|
25
|
+
* a managed agent whose task was queued before the restart would wait forever. Recently finished sessions are
|
|
26
|
+
* scanned too: a crash between `announceFinish()` and the delayed dispatch would otherwise leave the closing
|
|
27
|
+
* note undelivered. A delivered note is acked, so this never re-fires for a note that did arrive.
|
|
28
|
+
*/
|
|
29
|
+
resumePending() {
|
|
30
|
+
try {
|
|
31
|
+
this.hub.recoverEarlyIssueVotes();
|
|
32
|
+
const cutoff = Date.now() - CLOSING_NOTE_RETRY_WINDOW_MS;
|
|
33
|
+
const finished = this.hub.store.listSessions({ status: 'finished', limit: 50 });
|
|
34
|
+
for (const session of [...this.hub.store.listSessions({ status: 'active', limit: 200 }), ...finished]) {
|
|
35
|
+
const stale = session.status !== 'active' && Date.parse(session.updatedAt) < cutoff;
|
|
36
|
+
for (const participant of this.hub.store.listParticipants(session.sessionId)) {
|
|
37
|
+
if (!participant.agentId || participant.state !== 'active')
|
|
38
|
+
continue;
|
|
39
|
+
const pending = this.hub.store.listInbox(participant.participantId);
|
|
40
|
+
const last = pending[pending.length - 1];
|
|
41
|
+
// A finished session only ever has one deliverable left: its closing note.
|
|
42
|
+
if (!last || (session.status !== 'active' && last.type !== 'session_result'))
|
|
43
|
+
continue;
|
|
44
|
+
// Past the retry window nobody is waiting any more: stop re-trying.
|
|
45
|
+
if (stale) {
|
|
46
|
+
this.hub.completeDelivery(participant.participantId, 'session_result');
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
this.schedule(session.sessionId, participant.participantId, last.type);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
this.deps.log?.(`Collab dispatch resume failed: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async stop() {
|
|
58
|
+
this.closed = true;
|
|
59
|
+
this.unsubscribe?.();
|
|
60
|
+
this.unsubscribe = undefined;
|
|
61
|
+
for (const timer of this.pending.values())
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
this.pending.clear();
|
|
64
|
+
await Promise.allSettled([...this.inFlight.values()]);
|
|
65
|
+
}
|
|
66
|
+
/** Test seam: waits until every scheduled wake-up has been attempted. */
|
|
67
|
+
async drain() {
|
|
68
|
+
for (let guard = 0; guard < 50 && (this.pending.size || this.inFlight.size); guard++) {
|
|
69
|
+
await Promise.allSettled([...this.inFlight.values()]);
|
|
70
|
+
if (this.pending.size)
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, (this.deps.delayMs ?? 150) + 10));
|
|
72
|
+
}
|
|
73
|
+
await Promise.allSettled([...this.inFlight.values()]);
|
|
74
|
+
}
|
|
75
|
+
onEvent(event) {
|
|
76
|
+
if (event.type !== 'task_assigned')
|
|
77
|
+
return;
|
|
78
|
+
const participantId = String(event.payload.participantId ?? ''), task = String(event.payload.task ?? '');
|
|
79
|
+
if (!participantId || !task)
|
|
80
|
+
return;
|
|
81
|
+
this.schedule(event.sessionId, participantId, task);
|
|
82
|
+
}
|
|
83
|
+
schedule(sessionId, participantId, task) {
|
|
84
|
+
if (this.closed)
|
|
85
|
+
return;
|
|
86
|
+
const existing = this.pending.get(participantId);
|
|
87
|
+
if (existing)
|
|
88
|
+
clearTimeout(existing);
|
|
89
|
+
const timer = setTimeout(() => {
|
|
90
|
+
this.pending.delete(participantId);
|
|
91
|
+
void this.enqueue(sessionId, participantId, task);
|
|
92
|
+
}, this.deps.delayMs ?? 150);
|
|
93
|
+
timer.unref?.();
|
|
94
|
+
this.pending.set(participantId, timer);
|
|
95
|
+
}
|
|
96
|
+
/** One wake-up at a time per participant: two overlapping prompts would race inside the agent. */
|
|
97
|
+
enqueue(sessionId, participantId, task) {
|
|
98
|
+
const previous = this.inFlight.get(participantId) ?? Promise.resolve();
|
|
99
|
+
const job = previous.catch(() => { }).then(() => this.wake(sessionId, participantId, task));
|
|
100
|
+
const tracked = job.finally(() => {
|
|
101
|
+
if (this.inFlight.get(participantId) === tracked)
|
|
102
|
+
this.inFlight.delete(participantId);
|
|
103
|
+
});
|
|
104
|
+
this.inFlight.set(participantId, tracked);
|
|
105
|
+
return tracked;
|
|
106
|
+
}
|
|
107
|
+
async wake(sessionId, participantId, task) {
|
|
108
|
+
let participant;
|
|
109
|
+
try {
|
|
110
|
+
participant = this.hub.store.getParticipant(participantId);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const agentId = participant.agentId;
|
|
116
|
+
if (!agentId) {
|
|
117
|
+
// only a pre-managed-only row can get here
|
|
118
|
+
this.hub.logDispatch(sessionId, 'dispatch_failed', { participantId, task, reason: 'NO_AGENT_BOUND' });
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (participant.state !== 'active')
|
|
122
|
+
return; // left or out of budget: a human has to act
|
|
123
|
+
if (this.deps.isCollabAgent?.(agentId) === false) {
|
|
124
|
+
// undefined: the check is not wired up
|
|
125
|
+
this.hub.logDispatch(sessionId, 'dispatch_failed', {
|
|
126
|
+
participantId,
|
|
127
|
+
agentId,
|
|
128
|
+
task,
|
|
129
|
+
reason: 'COLLAB_PROFILE_REQUIRED'
|
|
130
|
+
});
|
|
131
|
+
this.deps.log?.(`Collab dispatch skipped for ${participant.displayName}: the Agent is not profile=collab.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const session = this.hub.store.findSession(sessionId);
|
|
135
|
+
// A human may reopen a review while the previous closing note is still scheduled. Never deliver that stale
|
|
136
|
+
// "finished" message into a new active cycle, and do not retire the freshly rotated credential with it.
|
|
137
|
+
const terminal = task === 'session_result';
|
|
138
|
+
if (!session)
|
|
139
|
+
return;
|
|
140
|
+
if (terminal && session.status !== 'finished') {
|
|
141
|
+
this.hub.completeDelivery(participantId, task);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!terminal && session.status !== 'active')
|
|
145
|
+
return;
|
|
146
|
+
// Delivery is serialised per seat and a `command()` only resolves when the agent's turn ends, so a wake-up
|
|
147
|
+
// queued at the start of a phase can arrive after the panel has already moved past it. Waking an agent to
|
|
148
|
+
// tell it "nothing to do" costs a full model turn, so the queued item is retired instead.
|
|
149
|
+
if (!terminal && this.hub.currentTaskFor(participantId) === 'wait') {
|
|
150
|
+
this.hub.completeDelivery(participantId, task);
|
|
151
|
+
this.delivered.delete(participantId);
|
|
152
|
+
this.hub.logDispatch(sessionId, 'dispatch_skipped', {
|
|
153
|
+
participantId,
|
|
154
|
+
agentId,
|
|
155
|
+
task,
|
|
156
|
+
reason: 'NOTHING_OWED',
|
|
157
|
+
phase: session.phase,
|
|
158
|
+
round: session.round
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
// The bound agent is part of the identity of a delivery: after a rebind the *new* agent has received
|
|
163
|
+
// nothing, so a key without it matches the old delivery and leaves the replacement agent idle.
|
|
164
|
+
const key = `${agentId}:${task}:${session.phase}:${session.round}:${session.debateRound}`;
|
|
165
|
+
// The prompt was already accepted in this process. Keep the durable item until collab_get_task actually
|
|
166
|
+
// collects it: followUp() only queues work and may be lost if the process stops before the next turn.
|
|
167
|
+
if (this.delivered.get(participantId) === key)
|
|
168
|
+
return;
|
|
169
|
+
const status = this.deps.agentStatus(agentId);
|
|
170
|
+
const kind = status && BUSY_STATES.includes(status) ? 'follow-up' : 'prompt';
|
|
171
|
+
try {
|
|
172
|
+
await this.deps.command(agentId, kind, toolBriefing({ session, participant, task }));
|
|
173
|
+
this.delivered.set(participantId, key);
|
|
174
|
+
this.hub.logDispatch(sessionId, 'agent_dispatched', {
|
|
175
|
+
participantId,
|
|
176
|
+
agentId,
|
|
177
|
+
task,
|
|
178
|
+
kind,
|
|
179
|
+
phase: session.phase,
|
|
180
|
+
round: session.round
|
|
181
|
+
});
|
|
182
|
+
// Normal work is acknowledged by collab_get_task, not here: followUp() returning means queued, not run.
|
|
183
|
+
// A closing note has no tool collection step, so successful completion remains its acknowledgement.
|
|
184
|
+
if (terminal) {
|
|
185
|
+
this.hub.completeDelivery(participantId, task);
|
|
186
|
+
this.delivered.delete(participantId);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
this.hub.logDispatch(sessionId, 'dispatch_failed', {
|
|
191
|
+
participantId,
|
|
192
|
+
agentId,
|
|
193
|
+
task,
|
|
194
|
+
reason: error.message
|
|
195
|
+
});
|
|
196
|
+
this.deps.log?.(`Collab dispatch to agent ${agentId} failed: ${error.message}`);
|
|
197
|
+
// The note did NOT arrive, so the item stays unacked: a restart retries it inside
|
|
198
|
+
// CLOSING_NOTE_RETRY_WINDOW_MS.
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** The only dispatcher prompt. Transport credentials/URLs, opaque ids, and task JSON stay in the Pi extension; the human-authored review request is repeated here deliberately. */
|
|
203
|
+
export function toolBriefing(input) {
|
|
204
|
+
if (input.task === 'session_result') {
|
|
205
|
+
const verdict = typeof input.session.outcome?.verdict === 'string' ? ` Result: ${input.session.outcome.verdict}.` : '';
|
|
206
|
+
return `[pi2web collaboration] This local code collaboration is finished.${verdict} Nothing further is required; end your turn.`;
|
|
207
|
+
}
|
|
208
|
+
return [
|
|
209
|
+
'[pi2web collaboration] You have a local code collaboration task.',
|
|
210
|
+
`role: ${input.participant.role}; action: ${input.task}; working directory: ${input.session.cwd}`,
|
|
211
|
+
`user request: ${input.session.subject.value}`,
|
|
212
|
+
...(input.session.subject.notes ? [`user notes: ${input.session.subject.notes}`] : []),
|
|
213
|
+
...(input.task === 'implement'
|
|
214
|
+
? []
|
|
215
|
+
: [
|
|
216
|
+
'This is a non-mutating task. You may use bash to inspect (for example git diff/log), but do not create, delete, rename, or modify anything under the working directory, including redirected output, scratch files, generated files, caches, or formatter changes.'
|
|
217
|
+
]),
|
|
218
|
+
'Call collab_get_task first. It repeats the request, supplies the pinned baseline and activates the exact collab_submit_* tool; use that tool to record the complete result.',
|
|
219
|
+
'Do not use curl, construct URLs, handle credentials, contact other agents, sleep, or poll. End your turn after submitting.'
|
|
220
|
+
].join('\n');
|
|
221
|
+
}
|