@moxxy/sdk 0.44.0 → 0.45.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/dist/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +1 -1
- package/dist/attachment-payload.js +1 -1
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +17 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +22 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +9 -67
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +33 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.ts +1 -1
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +17 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +121 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +11 -0
- package/src/mode/project-messages.ts +8 -61
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { MoxxyEvent } from './events.js';
|
|
3
|
+
import {
|
|
4
|
+
QUESTION_ASKED,
|
|
5
|
+
QUESTION_PLUGIN_ID,
|
|
6
|
+
QUESTION_RESOLVED,
|
|
7
|
+
describeQuestionAnswers,
|
|
8
|
+
openQuestionsFromEvents,
|
|
9
|
+
questionAnswersProblem,
|
|
10
|
+
questionRecord,
|
|
11
|
+
type QuestionItem,
|
|
12
|
+
} from './question.js';
|
|
13
|
+
|
|
14
|
+
let seq = 0;
|
|
15
|
+
const base = (turnId = 't1') => ({ id: `e${seq}`, seq: seq++, ts: 0, sessionId: 's', turnId }) as const;
|
|
16
|
+
const record = (subtype: string, payload: unknown, turnId?: string): MoxxyEvent =>
|
|
17
|
+
({ ...base(turnId), type: 'plugin_event', source: 'system', pluginId: QUESTION_PLUGIN_ID, subtype, payload }) as unknown as MoxxyEvent;
|
|
18
|
+
const prompt = (): MoxxyEvent => ({ ...base(), type: 'user_prompt', source: 'user', text: 'hi' }) as unknown as MoxxyEvent;
|
|
19
|
+
|
|
20
|
+
const colour: QuestionItem = {
|
|
21
|
+
id: 'q1',
|
|
22
|
+
header: 'Colour',
|
|
23
|
+
question: 'Which colour should the button be?',
|
|
24
|
+
options: [
|
|
25
|
+
{ id: 'red', label: 'Red' },
|
|
26
|
+
{ id: 'blue', label: 'Blue', description: 'Matches the logo' },
|
|
27
|
+
],
|
|
28
|
+
multiSelect: false,
|
|
29
|
+
allowOther: true,
|
|
30
|
+
};
|
|
31
|
+
const features: QuestionItem = {
|
|
32
|
+
id: 'q2',
|
|
33
|
+
question: 'Which parts should it cover?',
|
|
34
|
+
options: [
|
|
35
|
+
{ id: 'auth', label: 'Auth' },
|
|
36
|
+
{ id: 'billing', label: 'Billing' },
|
|
37
|
+
],
|
|
38
|
+
multiSelect: true,
|
|
39
|
+
allowOther: false,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('question records in the log', () => {
|
|
43
|
+
it('reads an asked and a resolved record, and nothing else', () => {
|
|
44
|
+
expect(questionRecord(record(QUESTION_ASKED, { questionId: 'x', questions: [colour] }))).toEqual({
|
|
45
|
+
kind: 'asked',
|
|
46
|
+
questionId: 'x',
|
|
47
|
+
turnId: 't1',
|
|
48
|
+
questions: [colour],
|
|
49
|
+
});
|
|
50
|
+
expect(questionRecord(record(QUESTION_RESOLVED, { questionId: 'x', answers: { q1: { selected: ['red'] } } }))).toEqual({
|
|
51
|
+
kind: 'resolved',
|
|
52
|
+
questionId: 'x',
|
|
53
|
+
outcome: { kind: 'answered', answers: { q1: { selected: ['red'] } } },
|
|
54
|
+
});
|
|
55
|
+
expect(questionRecord(record(QUESTION_RESOLVED, { questionId: 'x', dismissed: 'cancelled' }))).toEqual({
|
|
56
|
+
kind: 'resolved',
|
|
57
|
+
questionId: 'x',
|
|
58
|
+
outcome: { kind: 'dismissed', reason: 'cancelled' },
|
|
59
|
+
});
|
|
60
|
+
expect(questionRecord(prompt())).toBeNull();
|
|
61
|
+
expect(questionRecord(record('other', { questionId: 'x' }))).toBeNull();
|
|
62
|
+
expect(questionRecord(record(QUESTION_ASKED, { questionId: 'x', questions: 'nope' }))).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps open only the questions nothing has resolved yet, in the order they were asked', () => {
|
|
66
|
+
const events = [
|
|
67
|
+
record(QUESTION_ASKED, { questionId: 'a', questions: [colour] }, 't1'),
|
|
68
|
+
prompt(),
|
|
69
|
+
record(QUESTION_ASKED, { questionId: 'b', questions: [features] }, 't2'),
|
|
70
|
+
record(QUESTION_RESOLVED, { questionId: 'a', dismissed: 'cancelled' }),
|
|
71
|
+
record(QUESTION_ASKED, { questionId: 'c', questions: [colour] }, 't3'),
|
|
72
|
+
];
|
|
73
|
+
expect(openQuestionsFromEvents(events).map((q) => [q.questionId, q.turnId])).toEqual([
|
|
74
|
+
['b', 't2'],
|
|
75
|
+
['c', 't3'],
|
|
76
|
+
]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('has no open question in a conversation that never asked one', () => {
|
|
80
|
+
expect(openQuestionsFromEvents([prompt(), prompt()])).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('checking an answer against its question', () => {
|
|
85
|
+
const questions = [colour, features];
|
|
86
|
+
|
|
87
|
+
it('accepts one choice, an answer of the person own, or several choices where allowed', () => {
|
|
88
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['blue'] }, q2: { selected: ['auth', 'billing'] } })).toBeNull();
|
|
89
|
+
expect(questionAnswersProblem(questions, { q1: { selected: [], other: 'Teal' }, q2: { selected: ['auth'] } })).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('names what is wrong with an answer the question cannot take', () => {
|
|
93
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['blue'] } })).toBe('No answer to "Which parts should it cover?".');
|
|
94
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['red', 'blue'] }, q2: { selected: ['auth'] } })).toBe(
|
|
95
|
+
'"Which colour should the button be?" takes one answer.',
|
|
96
|
+
);
|
|
97
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['green'] }, q2: { selected: ['auth'] } })).toBe(
|
|
98
|
+
'"Which colour should the button be?" has no answer "green".',
|
|
99
|
+
);
|
|
100
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['red'] }, q2: { selected: [], other: 'Logs' } })).toBe(
|
|
101
|
+
'"Which parts should it cover?" takes only the answers it offers.',
|
|
102
|
+
);
|
|
103
|
+
expect(questionAnswersProblem(questions, { q1: { selected: [], other: ' ' }, q2: { selected: ['auth'] } })).toBe(
|
|
104
|
+
'No answer to "Which colour should the button be?".',
|
|
105
|
+
);
|
|
106
|
+
expect(questionAnswersProblem(questions, { q1: { selected: ['red'], other: 'Teal' }, q2: { selected: ['auth'] } })).toBe(
|
|
107
|
+
'"Which colour should the button be?" takes one answer.',
|
|
108
|
+
);
|
|
109
|
+
expect(
|
|
110
|
+
questionAnswersProblem(questions, { q1: { selected: ['red'] }, q2: { selected: ['auth'] }, q9: { selected: [] } }),
|
|
111
|
+
).toBe('There is no question "q9".');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('telling the agent what the person answered', () => {
|
|
116
|
+
it('quotes each question with the labels chosen and the words typed', () => {
|
|
117
|
+
expect(
|
|
118
|
+
describeQuestionAnswers([colour, features], {
|
|
119
|
+
q1: { selected: [], other: 'Teal' },
|
|
120
|
+
q2: { selected: ['auth', 'billing'] },
|
|
121
|
+
}),
|
|
122
|
+
).toBe(
|
|
123
|
+
[
|
|
124
|
+
'The user answered:',
|
|
125
|
+
'- "Which colour should the button be?" → their own answer: Teal',
|
|
126
|
+
'- "Which parts should it cover?" → Auth, Billing',
|
|
127
|
+
].join('\n'),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
package/src/question.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { z } from 'zod/v3';
|
|
2
|
+
import type { MoxxyEvent } from './events.js';
|
|
3
|
+
import { asPluginId, type TurnId } from './ids.js';
|
|
4
|
+
import type { QuestionAnswerDisplay } from './tool-display.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Question is the agent asking the person to choose or answer: it blocks no
|
|
8
|
+
* action and auto-approve never answers it. It belongs to the conversation, not
|
|
9
|
+
* to one client: asking and resolving are recorded in the event log as
|
|
10
|
+
* `plugin_event`s, so every surface attached to the session shows the same open
|
|
11
|
+
* question, and the first answer from any of them closes it everywhere.
|
|
12
|
+
*/
|
|
13
|
+
export const QUESTION_PLUGIN_ID = asPluginId('@moxxy/question');
|
|
14
|
+
export const QUESTION_ASKED = 'question_asked';
|
|
15
|
+
export const QUESTION_RESOLVED = 'question_resolved';
|
|
16
|
+
|
|
17
|
+
export interface QuestionOption {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly label: string;
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface QuestionItem {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
/** A short tag for the question, as Claude's AskUserQuestion and Codex send it. */
|
|
26
|
+
readonly header?: string;
|
|
27
|
+
readonly question: string;
|
|
28
|
+
/** Empty when only an answer of the person's own fits. */
|
|
29
|
+
readonly options: ReadonlyArray<QuestionOption>;
|
|
30
|
+
readonly multiSelect: boolean;
|
|
31
|
+
/** Whether the person may answer in their own words instead of an option. */
|
|
32
|
+
readonly allowOther: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The ids of the options chosen, and the words typed when the question takes them. */
|
|
36
|
+
export interface QuestionAnswer {
|
|
37
|
+
readonly selected: ReadonlyArray<string>;
|
|
38
|
+
readonly other?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type QuestionAnswers = Readonly<Record<string, QuestionAnswer>>;
|
|
42
|
+
|
|
43
|
+
export type QuestionOutcome =
|
|
44
|
+
| { readonly kind: 'answered'; readonly answers: QuestionAnswers }
|
|
45
|
+
/** Closed before anyone answered: the turn waiting on it stopped, or `note` says why else. */
|
|
46
|
+
| { readonly kind: 'dismissed'; readonly reason: 'cancelled'; readonly note?: QuestionCloseNote }
|
|
47
|
+
/** No surface that can show a question is attached, so nobody will answer. */
|
|
48
|
+
| { readonly kind: 'unanswerable' };
|
|
49
|
+
|
|
50
|
+
/** The agent went on working without the answer, so nothing waits on it any more. */
|
|
51
|
+
export const QUESTION_CLOSED_AGENT_WENT_ON = 'agent-went-on';
|
|
52
|
+
/** The person sent a new message instead of answering. */
|
|
53
|
+
export const QUESTION_CLOSED_NEW_MESSAGE = 'new-message';
|
|
54
|
+
/** Why a question closed unanswered, when it was not the turn stopping. */
|
|
55
|
+
export type QuestionCloseNote = typeof QUESTION_CLOSED_AGENT_WENT_ON | typeof QUESTION_CLOSED_NEW_MESSAGE;
|
|
56
|
+
|
|
57
|
+
/** The close note `value` names, or undefined for anything else (an abort reason, a note a newer Moxxy wrote). */
|
|
58
|
+
export function questionCloseNote(value: unknown): QuestionCloseNote | undefined {
|
|
59
|
+
return value === QUESTION_CLOSED_AGENT_WENT_ON || value === QUESTION_CLOSED_NEW_MESSAGE ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How the asker of a question in a turn may close it before the turn ends:
|
|
64
|
+
* aborting `signal` closes it unanswered, with the abort reason as its note
|
|
65
|
+
* when that is a {@link QuestionCloseNote}.
|
|
66
|
+
*/
|
|
67
|
+
export interface QuestionAskOptions {
|
|
68
|
+
readonly signal?: AbortSignal;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Asks the person and waits: answered, stopped with the turn, or nobody attached to answer. */
|
|
72
|
+
export type QuestionAsker = (request: {
|
|
73
|
+
readonly turnId: TurnId;
|
|
74
|
+
readonly questions: ReadonlyArray<QuestionItem>;
|
|
75
|
+
readonly signal: AbortSignal;
|
|
76
|
+
}) => Promise<QuestionOutcome>;
|
|
77
|
+
|
|
78
|
+
/** The session's side of questions that a surface (desktop, TUI, a channel bot) drives. */
|
|
79
|
+
export interface SessionQuestions {
|
|
80
|
+
/** Say this surface can show questions and take answers; call the result when it detaches. */
|
|
81
|
+
attachSurface(): () => void;
|
|
82
|
+
/** Answer an open question. Rejects when it is no longer open or the answer does not fit it. */
|
|
83
|
+
answer(questionId: string, answers: QuestionAnswers): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const questionOptionSchema = z
|
|
87
|
+
.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional() })
|
|
88
|
+
.strict();
|
|
89
|
+
|
|
90
|
+
export const questionItemSchema = z
|
|
91
|
+
.object({
|
|
92
|
+
id: z.string().min(1),
|
|
93
|
+
header: z.string().optional(),
|
|
94
|
+
question: z.string().min(1),
|
|
95
|
+
options: z.array(questionOptionSchema),
|
|
96
|
+
multiSelect: z.boolean(),
|
|
97
|
+
allowOther: z.boolean(),
|
|
98
|
+
})
|
|
99
|
+
.strict()
|
|
100
|
+
.refine((item) => item.options.length > 0 || item.allowOther, 'A question without options must take an answer of the person own.');
|
|
101
|
+
|
|
102
|
+
export const questionAnswerSchema = z
|
|
103
|
+
.object({ selected: z.array(z.string()), other: z.string().optional() })
|
|
104
|
+
.strict();
|
|
105
|
+
|
|
106
|
+
export const questionAnswersSchema = z.record(questionAnswerSchema);
|
|
107
|
+
|
|
108
|
+
const askedSchema = z.object({ questionId: z.string().min(1), questions: z.array(questionItemSchema).min(1) });
|
|
109
|
+
const resolvedSchema = z.union([
|
|
110
|
+
z.object({ questionId: z.string().min(1), answers: questionAnswersSchema }),
|
|
111
|
+
// Any note is read, so a record a newer Moxxy wrote still closes its question here.
|
|
112
|
+
z.object({ questionId: z.string().min(1), dismissed: z.literal('cancelled'), note: z.string().optional() }),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
export type QuestionRecord =
|
|
116
|
+
| {
|
|
117
|
+
readonly kind: 'asked';
|
|
118
|
+
readonly questionId: string;
|
|
119
|
+
readonly turnId: string;
|
|
120
|
+
readonly questions: ReadonlyArray<QuestionItem>;
|
|
121
|
+
}
|
|
122
|
+
| {
|
|
123
|
+
readonly kind: 'resolved';
|
|
124
|
+
readonly questionId: string;
|
|
125
|
+
readonly outcome: Extract<QuestionOutcome, { kind: 'answered' | 'dismissed' }>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
function dismissedOutcome(note: QuestionCloseNote | undefined): Extract<QuestionOutcome, { kind: 'dismissed' }> {
|
|
129
|
+
return note ? { kind: 'dismissed', reason: 'cancelled', note } : { kind: 'dismissed', reason: 'cancelled' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** What a question record in the log says, or null when the event is not one. */
|
|
133
|
+
export function questionRecord(event: MoxxyEvent): QuestionRecord | null {
|
|
134
|
+
if (event.type !== 'plugin_event' || event.pluginId !== QUESTION_PLUGIN_ID) return null;
|
|
135
|
+
if (event.subtype === QUESTION_ASKED) {
|
|
136
|
+
const parsed = askedSchema.safeParse(event.payload);
|
|
137
|
+
if (!parsed.success) return null;
|
|
138
|
+
return { kind: 'asked', questionId: parsed.data.questionId, turnId: String(event.turnId), questions: parsed.data.questions };
|
|
139
|
+
}
|
|
140
|
+
if (event.subtype === QUESTION_RESOLVED) {
|
|
141
|
+
const parsed = resolvedSchema.safeParse(event.payload);
|
|
142
|
+
if (!parsed.success) return null;
|
|
143
|
+
const outcome =
|
|
144
|
+
'answers' in parsed.data
|
|
145
|
+
? ({ kind: 'answered', answers: parsed.data.answers } as const)
|
|
146
|
+
: dismissedOutcome(questionCloseNote(parsed.data.note));
|
|
147
|
+
return { kind: 'resolved', questionId: parsed.data.questionId, outcome };
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface OpenQuestion {
|
|
153
|
+
readonly questionId: string;
|
|
154
|
+
readonly turnId: string;
|
|
155
|
+
readonly questions: ReadonlyArray<QuestionItem>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The questions asked and not yet resolved, oldest first: a pure fold over the log. */
|
|
159
|
+
export function openQuestionsFromEvents(events: ReadonlyArray<MoxxyEvent>): ReadonlyArray<OpenQuestion> {
|
|
160
|
+
const open = new Map<string, OpenQuestion>();
|
|
161
|
+
for (const event of events) {
|
|
162
|
+
const found = questionRecord(event);
|
|
163
|
+
if (!found) continue;
|
|
164
|
+
if (found.kind === 'asked') {
|
|
165
|
+
open.set(found.questionId, { questionId: found.questionId, turnId: found.turnId, questions: found.questions });
|
|
166
|
+
} else {
|
|
167
|
+
open.delete(found.questionId);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...open.values()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Why `answers` cannot answer `questions`, in one line, or null when they can. */
|
|
174
|
+
export function questionAnswersProblem(
|
|
175
|
+
questions: ReadonlyArray<QuestionItem>,
|
|
176
|
+
answers: QuestionAnswers,
|
|
177
|
+
): string | null {
|
|
178
|
+
const known = new Set(questions.map((item) => item.id));
|
|
179
|
+
const stray = Object.keys(answers).find((id) => !known.has(id));
|
|
180
|
+
if (stray !== undefined) return `There is no question "${stray}".`;
|
|
181
|
+
|
|
182
|
+
for (const item of questions) {
|
|
183
|
+
const answer = answers[item.id];
|
|
184
|
+
const other = typedWords(answer);
|
|
185
|
+
const chosen = answer ? answer.selected.length : 0;
|
|
186
|
+
if (chosen === 0 && other === '') return `No answer to "${item.question}".`;
|
|
187
|
+
if (answer === undefined) continue;
|
|
188
|
+
const offered = new Set(item.options.map((option) => option.id));
|
|
189
|
+
const unknown = answer.selected.find((id) => !offered.has(id));
|
|
190
|
+
if (unknown !== undefined) return `"${item.question}" has no answer "${unknown}".`;
|
|
191
|
+
if (other !== '' && !item.allowOther) return `"${item.question}" takes only the answers it offers.`;
|
|
192
|
+
if (!item.multiSelect && chosen + (other === '' ? 0 : 1) > 1) return `"${item.question}" takes one answer.`;
|
|
193
|
+
}
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The person's answers as the agent reads them: each question quoted with what was chosen. */
|
|
198
|
+
export function describeQuestionAnswers(questions: ReadonlyArray<QuestionItem>, answers: QuestionAnswers): string {
|
|
199
|
+
const lines = questions.map((item) => {
|
|
200
|
+
const answer = answers[item.id];
|
|
201
|
+
const labels = (answer?.selected ?? []).map((id) => item.options.find((option) => option.id === id)?.label ?? id);
|
|
202
|
+
const other = typedWords(answer);
|
|
203
|
+
const parts = [...(labels.length > 0 ? [labels.join(', ')] : []), ...(other ? [`their own answer: ${other}`] : [])];
|
|
204
|
+
return `- "${item.question}" → ${parts.join('; ')}`;
|
|
205
|
+
});
|
|
206
|
+
return ['The user answered:', ...lines].join('\n');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The answers as the transcript shows them: each question with the labels picked and any words of the person's own. */
|
|
210
|
+
export function questionAnswerDisplay(questions: ReadonlyArray<QuestionItem>, answers: QuestionAnswers): QuestionAnswerDisplay {
|
|
211
|
+
return {
|
|
212
|
+
kind: 'question-answer',
|
|
213
|
+
questions: questions.map((item) => {
|
|
214
|
+
const answer = answers[item.id];
|
|
215
|
+
const chosen = (answer?.selected ?? []).map((id) => item.options.find((option) => option.id === id)?.label ?? id);
|
|
216
|
+
const own = typedWords(answer);
|
|
217
|
+
return {
|
|
218
|
+
...(item.header ? { header: item.header } : {}),
|
|
219
|
+
question: item.question,
|
|
220
|
+
chosen,
|
|
221
|
+
...(own ? { own } : {}),
|
|
222
|
+
};
|
|
223
|
+
}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function typedWords(answer: QuestionAnswer | undefined): string {
|
|
228
|
+
return answer && answer.other !== undefined ? answer.other.trim() : '';
|
|
229
|
+
}
|
package/src/questions.ts
ADDED
package/src/resolvers.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { createAllowListResolver, createDeferredPermissionResolver, evaluateToolRule } from './resolvers.js';
|
|
2
|
+
import { alwaysAllowRule, createAllowListResolver, createDeferredPermissionResolver, createSessionGrants, evaluateToolRule } from './resolvers.js';
|
|
3
3
|
import type { PendingToolCall, PermissionRule } from './permission.js';
|
|
4
4
|
|
|
5
5
|
function call(name: string, input: unknown = {}): PendingToolCall {
|
|
@@ -83,3 +83,86 @@ describe('a decision made now', () => {
|
|
|
83
83
|
expect((await createAllowListResolver(['computer_run']).check(call('computer_run', {}), { sessionId: 's' })).decidedNow).toBe(true);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
describe('a plain allow is one call, not a session grant', () => {
|
|
88
|
+
it('asks again for the same tool after "allow" (one call only)', async () => {
|
|
89
|
+
let prompts = 0;
|
|
90
|
+
const resolver = createDeferredPermissionResolver({
|
|
91
|
+
prompt: async () => {
|
|
92
|
+
prompts += 1;
|
|
93
|
+
return { mode: 'allow' };
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const ctx = { sessionId: 's' };
|
|
97
|
+
await resolver.check(call('computer_click', { app: 'LM Studio' }), ctx);
|
|
98
|
+
await resolver.check(call('computer_click', { app: 'LM Studio' }), ctx);
|
|
99
|
+
expect(prompts).toBe(2);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('does not ask again for the same tool after "allow_session"', async () => {
|
|
103
|
+
let prompts = 0;
|
|
104
|
+
const resolver = createDeferredPermissionResolver({
|
|
105
|
+
prompt: async () => {
|
|
106
|
+
prompts += 1;
|
|
107
|
+
return { mode: 'allow_session' };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
const ctx = { sessionId: 's' };
|
|
111
|
+
await resolver.check(call('computer_click', { app: 'LM Studio' }), ctx);
|
|
112
|
+
await resolver.check(call('computer_click', { app: 'Safari' }), ctx);
|
|
113
|
+
expect(prompts).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// computer_request_access grants an app by itself: an answer for Safari must not let Terminal through.
|
|
118
|
+
describe('a tool that narrows its grant to the same request', () => {
|
|
119
|
+
const narrowed: PermissionRule = { action: 'prompt', sessionScope: { inputKeys: ['apps', 'full_access'] } };
|
|
120
|
+
const ctx = { sessionId: 's', toolPermission: narrowed };
|
|
121
|
+
|
|
122
|
+
it.each(['allow_session', 'allow_always'] as const)('remembers only that request after "%s", in this conversation', async (mode) => {
|
|
123
|
+
let prompts = 0;
|
|
124
|
+
const resolver = createDeferredPermissionResolver({
|
|
125
|
+
prompt: async () => {
|
|
126
|
+
prompts += 1;
|
|
127
|
+
return { mode };
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await resolver.check(call('grant_access', { apps: ['Safari'], full_access: true, reason: 'a' }), ctx);
|
|
132
|
+
await resolver.check(call('grant_access', { apps: ['Safari'], full_access: true, reason: 'b' }), ctx);
|
|
133
|
+
await resolver.check(call('grant_access', { apps: ['Terminal'], full_access: true }), ctx);
|
|
134
|
+
await resolver.check(call('grant_access', { apps: ['Safari'] }), ctx);
|
|
135
|
+
|
|
136
|
+
expect(prompts).toBe(3);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('is remembered the same way by a channel that keeps its own grants', () => {
|
|
140
|
+
const grants = createSessionGrants();
|
|
141
|
+
grants.remember(call('grant_access', { apps: ['Safari'], full_access: true }), { mode: 'allow_session' }, ctx);
|
|
142
|
+
|
|
143
|
+
expect(grants.covers(call('grant_access', { apps: ['Safari'], full_access: true }))).toBe(true);
|
|
144
|
+
expect(grants.covers(call('grant_access', { apps: ['Terminal'], full_access: true }))).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('is saved for later conversations as that exact request, a left-out value as null', () => {
|
|
148
|
+
expect(alwaysAllowRule(call('grant_access', { apps: ['Safari'], reason: 'browse' }), ctx)).toEqual({
|
|
149
|
+
name: 'grant_access',
|
|
150
|
+
inputEquals: { apps: ['Safari'], full_access: null },
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('is saved as the whole tool for a tool that does not narrow its grant', () => {
|
|
155
|
+
expect(alwaysAllowRule(call('Write', { file_path: 'a.ts' }), { sessionId: 's' })).toEqual({ name: 'Write' });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('session grants of a channel', () => {
|
|
160
|
+
it('cover the whole tool after "allow_session" for a tool that does not narrow it, and nothing after "allow"', () => {
|
|
161
|
+
const grants = createSessionGrants();
|
|
162
|
+
grants.remember(call('computer_click', { app: 'Notes' }), { mode: 'allow' }, { sessionId: 's' });
|
|
163
|
+
expect(grants.covers(call('computer_click', { app: 'Notes' }))).toBe(false);
|
|
164
|
+
|
|
165
|
+
grants.remember(call('computer_click', { app: 'Notes' }), { mode: 'allow_session' }, { sessionId: 's' });
|
|
166
|
+
expect(grants.covers(call('computer_click', { app: 'Mail' }))).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
package/src/resolvers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
PermissionDecision,
|
|
5
5
|
PermissionResolver,
|
|
6
6
|
PermissionRule,
|
|
7
|
+
StandingAllowRule,
|
|
7
8
|
} from './permission.js';
|
|
8
9
|
|
|
9
10
|
export const autoAllowResolver: PermissionResolver = {
|
|
@@ -77,21 +78,12 @@ export function createDeferredPermissionResolver(
|
|
|
77
78
|
opts: DeferredPermissionResolverOptions,
|
|
78
79
|
): DeferredPermissionResolver {
|
|
79
80
|
const sessionAllows = opts.sessionAllows ?? new Set<string>();
|
|
80
|
-
const
|
|
81
|
-
toolName: string;
|
|
82
|
-
inputKeys: ReadonlyArray<string>;
|
|
83
|
-
signature: string;
|
|
84
|
-
}> = [];
|
|
81
|
+
const grants = createSessionGrants();
|
|
85
82
|
const pending = new Set<(d: PermissionDecision) => void>();
|
|
86
83
|
return {
|
|
87
84
|
name: opts.name ?? 'deferred',
|
|
88
85
|
async check(call, ctx) {
|
|
89
|
-
|
|
90
|
-
(grant) =>
|
|
91
|
-
grant.toolName === call.name &&
|
|
92
|
-
grant.signature === scopedGrantKey(call, grant.inputKeys),
|
|
93
|
-
);
|
|
94
|
-
if (sessionAllows.has(call.name) || scopedAllowed) {
|
|
86
|
+
if (sessionAllows.has(call.name) || grants.covers(call)) {
|
|
95
87
|
return { mode: 'allow_session', reason: 'allow_session previously granted' };
|
|
96
88
|
}
|
|
97
89
|
const decision = await new Promise<PermissionDecision>((resolve) => {
|
|
@@ -107,22 +99,9 @@ export function createDeferredPermissionResolver(
|
|
|
107
99
|
},
|
|
108
100
|
);
|
|
109
101
|
});
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
// additionally signals to the caller (via the decision flag) that
|
|
114
|
-
// the rule should be persisted to ~/.moxxy/permissions.json — but
|
|
115
|
-
// that persistence isn't our job; the channel does it when wiring
|
|
116
|
-
// up the dialog.
|
|
117
|
-
if (decision.mode === 'allow_session' && decision.sessionScope) {
|
|
118
|
-
scopedSessionAllows.push({
|
|
119
|
-
toolName: call.name,
|
|
120
|
-
inputKeys: decision.sessionScope.inputKeys,
|
|
121
|
-
signature: scopedGrantKey(call, decision.sessionScope.inputKeys),
|
|
122
|
-
});
|
|
123
|
-
} else if (decision.mode === 'allow_session' || decision.mode === 'allow_always') {
|
|
124
|
-
sessionAllows.add(call.name);
|
|
125
|
-
}
|
|
102
|
+
// allow_always also skips later prompts in this conversation; saving it
|
|
103
|
+
// to ~/.moxxy/permissions.json is the channel's job (see alwaysAllowRule).
|
|
104
|
+
grants.remember(call, decision, ctx);
|
|
126
105
|
return decision.mode === 'deny' ? decision : { ...decision, decidedNow: true };
|
|
127
106
|
},
|
|
128
107
|
abortAll(reason = 'channel closed') {
|
|
@@ -132,6 +111,40 @@ export function createDeferredPermissionResolver(
|
|
|
132
111
|
};
|
|
133
112
|
}
|
|
134
113
|
|
|
114
|
+
/** What a conversation's "allow for this session" and "always allow" answers cover. */
|
|
115
|
+
export interface SessionGrants {
|
|
116
|
+
covers(call: PendingToolCall): boolean;
|
|
117
|
+
/** Remember an answer: the same request only when the answer or the tool narrows it, else the whole tool. */
|
|
118
|
+
remember(call: PendingToolCall, decision: PermissionDecision, ctx: PermissionContext): void;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function createSessionGrants(): SessionGrants {
|
|
122
|
+
const tools = new Set<string>();
|
|
123
|
+
const requests: Array<{ readonly inputKeys: ReadonlyArray<string>; readonly key: string }> = [];
|
|
124
|
+
return {
|
|
125
|
+
covers: (call) =>
|
|
126
|
+
tools.has(call.name) || requests.some((grant) => grant.key === scopedGrantKey(call, grant.inputKeys)),
|
|
127
|
+
remember(call, decision, ctx) {
|
|
128
|
+
if (decision.mode !== 'allow_session' && decision.mode !== 'allow_always') return;
|
|
129
|
+
const scope = decision.sessionScope ?? ctx.toolPermission?.sessionScope;
|
|
130
|
+
if (scope) requests.push({ inputKeys: scope.inputKeys, key: scopedGrantKey(call, scope.inputKeys) });
|
|
131
|
+
else tools.add(call.name);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The rule an "Always allow" answer saves: the whole tool, or, for a tool that
|
|
138
|
+
* narrows its grant to the same request, exactly that request, so a later
|
|
139
|
+
* conversation is not let through for something nobody approved.
|
|
140
|
+
*/
|
|
141
|
+
export function alwaysAllowRule(call: PendingToolCall, ctx: PermissionContext): StandingAllowRule {
|
|
142
|
+
const keys = ctx.toolPermission?.sessionScope?.inputKeys;
|
|
143
|
+
if (!keys) return { name: call.name };
|
|
144
|
+
const input = isRecord(call.input) ? call.input : {};
|
|
145
|
+
return { name: call.name, inputEquals: Object.fromEntries(keys.map((key) => [key, input[key] ?? null])) };
|
|
146
|
+
}
|
|
147
|
+
|
|
135
148
|
function scopedGrantKey(call: PendingToolCall, inputKeys?: ReadonlyArray<string>): string {
|
|
136
149
|
const input = isRecord(call.input) ? call.input : {};
|
|
137
150
|
const keys = inputKeys ?? Object.keys(input).sort();
|
package/src/schemas.test.ts
CHANGED
|
@@ -40,6 +40,11 @@ describe('skillFrontmatterSchema', () => {
|
|
|
40
40
|
it('rejects empty description', () => {
|
|
41
41
|
expect(() => skillFrontmatterSchema.parse({ name: 'x', description: '' })).toThrow();
|
|
42
42
|
});
|
|
43
|
+
|
|
44
|
+
it('accepts a description up to 1024 chars, the Agent Skills limit third-party skills are written to', () => {
|
|
45
|
+
expect(skillFrontmatterSchema.parse({ name: 'x', description: 'd'.repeat(1024) }).description).toHaveLength(1024);
|
|
46
|
+
expect(() => skillFrontmatterSchema.parse({ name: 'x', description: 'd'.repeat(1025) })).toThrow();
|
|
47
|
+
});
|
|
43
48
|
});
|
|
44
49
|
|
|
45
50
|
describe('pluginManifestSchema', () => {
|
package/src/schemas.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { z } from 'zod';
|
|
1
|
+
import { z } from 'zod/v3';
|
|
2
2
|
|
|
3
3
|
const pluginKindSchema = z.enum([
|
|
4
4
|
'tools',
|
|
@@ -63,7 +63,8 @@ export const skillScheduleSchema = z
|
|
|
63
63
|
|
|
64
64
|
export const skillFrontmatterSchema = z.object({
|
|
65
65
|
name: z.string().min(1).max(120).regex(/^[a-z0-9][a-z0-9-]*$/, 'name must be slug-like'),
|
|
66
|
-
|
|
66
|
+
// 1024 is the Agent Skills limit; installers like `npx skills` ship skills written to it.
|
|
67
|
+
description: z.string().min(1).max(1024),
|
|
67
68
|
triggers: z.array(z.string().min(1)).optional(),
|
|
68
69
|
'allowed-tools': z.array(z.string().min(1)).optional(),
|
|
69
70
|
aliases: z.array(z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, 'alias must be slug-like')).optional(),
|
package/src/server.ts
CHANGED
|
@@ -47,6 +47,9 @@ export { utf8Locale } from './locale.js';
|
|
|
47
47
|
// options type is re-exported from the main barrel like other erased types.
|
|
48
48
|
export { CrossProcessFireLock } from './cross-process-lock.js';
|
|
49
49
|
export { readRequestBody, bearerTokenMatches } from './http-utils.js';
|
|
50
|
+
export { fetchNpmLatest, type FetchNpmLatestOptions } from './npm-registry.js';
|
|
51
|
+
// Stop a child and everything it started (process group / taskkill /T).
|
|
52
|
+
export { killTree } from './process-tree.js';
|
|
50
53
|
// Dedicated-channel run-status file: a channel publishes readiness + its public
|
|
51
54
|
// ingest URL so the desktop "Channels" supervisor can observe it out-of-process
|
|
52
55
|
// (node:fs). The `ChannelRunStatus` *type* rides the main barrel like the others.
|
package/src/session-like.ts
CHANGED
|
@@ -3,8 +3,9 @@ import type { SessionId, TurnId } from './ids.js';
|
|
|
3
3
|
import type { CapabilitySpec } from './isolation.js';
|
|
4
4
|
import type { EventLogReader } from './log.js';
|
|
5
5
|
import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
6
|
+
import type { SessionQuestions } from './question.js';
|
|
6
7
|
import type { PermissionResolver } from './permission.js';
|
|
7
|
-
import type { ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
|
|
8
|
+
import type { AgentBackgroundWork, AgentScheduledWakeup, AgentCliReport, ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
|
|
8
9
|
import type { PluginSetupSpec } from './schemas.js';
|
|
9
10
|
import type { ToolIcon } from './tool-icon.js';
|
|
10
11
|
import type { ToolCompactPresentation } from './tool.js';
|
|
@@ -63,10 +64,17 @@ export type ProviderAuthKind = 'api-key' | 'oauth';
|
|
|
63
64
|
export interface ProviderInfo {
|
|
64
65
|
readonly name: string;
|
|
65
66
|
readonly models: ReadonlyArray<ModelDescriptor>;
|
|
67
|
+
/** The light model a new conversation starts on while the person chose no
|
|
68
|
+
* default (see `startingModel`); absent when the provider names none. */
|
|
69
|
+
readonly starterModel?: string;
|
|
66
70
|
/** 'oauth' when the provider declares an oauth login on its plugin
|
|
67
71
|
* definition, 'api-key' otherwise. Defaults to 'api-key' for
|
|
68
72
|
* providers that don't declare. */
|
|
69
73
|
readonly authKind: ProviderAuthKind;
|
|
74
|
+
/** For an agent connection, the agent's name as the person knows it ("Codex"). */
|
|
75
|
+
readonly agentName?: string;
|
|
76
|
+
/** For an agent connection, the last check of its CLI; absent until the first check ends, and from older runners. */
|
|
77
|
+
readonly agentCli?: AgentCliReport;
|
|
70
78
|
/** True when the provider's plugin can list its models live (e.g.
|
|
71
79
|
* via /v1/models). Lets the desktop's model picker show a
|
|
72
80
|
* "Fetch live" affordance only where it makes sense. */
|
|
@@ -175,6 +183,10 @@ export interface SessionInfo {
|
|
|
175
183
|
/** Turns running now, whichever client (or in-process channel) started them.
|
|
176
184
|
* Reported by a runner to its clients; absent from runners that predate it. */
|
|
177
185
|
readonly runningTurns?: ReadonlyArray<string>;
|
|
186
|
+
/** What the conversation's agent runs in the background now; absent from runners that predate it. */
|
|
187
|
+
readonly backgroundWork?: ReadonlyArray<AgentBackgroundWork>;
|
|
188
|
+
/** When the conversation's agent set itself to come back by itself; absent from runners that predate it. */
|
|
189
|
+
readonly scheduledWakeups?: ReadonlyArray<AgentScheduledWakeup>;
|
|
178
190
|
}
|
|
179
191
|
|
|
180
192
|
/**
|
|
@@ -558,6 +570,12 @@ export interface SessionLike {
|
|
|
558
570
|
pluginsAdmin?: PluginsAdminView;
|
|
559
571
|
/** Provider onboarding slice backing in-channel connect (key entry / OAuth). */
|
|
560
572
|
providerSetup?: ProviderSetupView;
|
|
573
|
+
/**
|
|
574
|
+
* Questions the agent asks the person. Open questions are read from the log
|
|
575
|
+
* with `openQuestionsFromEvents`; this slice attaches a surface and answers.
|
|
576
|
+
* Optional capability per the seam convention: callers MUST guard.
|
|
577
|
+
*/
|
|
578
|
+
readonly questions?: SessionQuestions;
|
|
561
579
|
/**
|
|
562
580
|
* Re-read the merged config from disk and live-apply the safe subset —
|
|
563
581
|
* backs the TUI /settings panel's write-then-apply. Structurally matches
|
|
@@ -5,7 +5,7 @@ import type { Skill, SkillFrontmatter } from './skill.js';
|
|
|
5
5
|
import { insertMention, mentionedSkills, mentionOptions, mentionQueryAt, skillAttachment, withoutTools } from './skill-mentions.js';
|
|
6
6
|
import type { SkillInfo } from './session-like.js';
|
|
7
7
|
import { defineTool } from './define.js';
|
|
8
|
-
import { z } from 'zod';
|
|
8
|
+
import { z } from 'zod/v3';
|
|
9
9
|
|
|
10
10
|
const skill = (frontmatter: Omit<SkillFrontmatter, 'description'>, body = 'Do it on the desktop.'): Skill => ({
|
|
11
11
|
id: asSkillId(`plugin/${frontmatter.name}`),
|