@pulsarinteractive/geniustasker 0.0.0-stage → 0.1.0-beta.1
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 +399 -2
- package/bin/geniustasker.mjs +6 -0
- package/package.json +36 -4
- package/skills/geniustasker/SKILL.md +215 -0
- package/src/changes.mjs +63 -0
- package/src/cli.mjs +135 -0
- package/src/client.mjs +95 -0
- package/src/command-catalog.mjs +287 -0
- package/src/commands.mjs +114 -0
- package/src/errors.mjs +22 -0
- package/src/login.mjs +115 -0
- package/src/mcp.mjs +60 -0
- package/src/profiles.mjs +63 -0
- package/src/store.mjs +66 -0
- package/src/transport.mjs +68 -0
package/src/changes.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
2
|
+
import * as z from 'zod/v4';
|
|
3
|
+
import { AgentClient } from './client.mjs';
|
|
4
|
+
import { Transport } from './transport.mjs';
|
|
5
|
+
import catalog from './command-catalog.mjs';
|
|
6
|
+
import { fail } from './errors.mjs';
|
|
7
|
+
|
|
8
|
+
export const changesSchema = z.object({ scope: z.string().max(256).regex(/^(project|memory):[^\u0000-\u001f]+$/u),
|
|
9
|
+
collection: z.string().min(1).max(32), cursor: z.string().min(1).max(4096) }).strict();
|
|
10
|
+
export const watchSchema = changesSchema.extend({ waitSeconds: z.number().int().min(1).max(catalog.changes.maxWatchSeconds).optional() }).strict();
|
|
11
|
+
const revision = z.string().max(16).regex(/^(0|[1-9][0-9]*)$/).refine(value => BigInt(value) <= 9007199254740991n);
|
|
12
|
+
const changePageSchema = z.object({ scope: z.string(), collection: z.string(), epoch: z.string().min(1), checkpoint: revision,
|
|
13
|
+
items: z.array(z.discriminatedUnion('kind', [
|
|
14
|
+
z.object({ kind: z.literal('upsert'), id: z.string().min(1), revision, data: z.record(z.string(), z.unknown()) }).strict(),
|
|
15
|
+
z.object({ kind: z.literal('remove'), id: z.string().min(1), revision, data: z.null() }).strict(),
|
|
16
|
+
])), omittedIds: z.array(z.string().min(1)), nextCursor: z.string().min(1).max(4096), hasMore: z.boolean(),
|
|
17
|
+
pollAfterSeconds: z.number().int().min(1).max(86400),
|
|
18
|
+
}).strict();
|
|
19
|
+
function query(input, schema) {
|
|
20
|
+
const parsed = schema.safeParse(input);
|
|
21
|
+
if (!parsed.success) fail('invalid_arguments', 'Use a project or memory scope, collection and unchanged change cursor; waitSeconds must be within the supported bound.');
|
|
22
|
+
return parsed.data;
|
|
23
|
+
}
|
|
24
|
+
export async function readChanges(client, input) {
|
|
25
|
+
const requested = query(input, changesSchema), page = await client.get('changes', requested);
|
|
26
|
+
if (!changePageSchema.safeParse(page).success || page.scope !== requested.scope || page.collection !== requested.collection)
|
|
27
|
+
fail('invalid_response', 'The change page was invalid; keep the previous cursor.');
|
|
28
|
+
return page;
|
|
29
|
+
}
|
|
30
|
+
/** One bounded wait, not a daemon. Each poll spends the same account budget as
|
|
31
|
+
* any other read and repeats authorization. Errors are surfaced immediately;
|
|
32
|
+
* quotas, expired history and revocation never trigger a blind retry loop. */
|
|
33
|
+
export async function watchChanges(client, input, requestSignal) {
|
|
34
|
+
const { waitSeconds = 30, ...requested } = query(input, watchSchema);
|
|
35
|
+
const deadline = new AbortController(), parents = [client.transport.signal, requestSignal].filter(Boolean);
|
|
36
|
+
const parent = parents.length ? AbortSignal.any(parents) : undefined;
|
|
37
|
+
const signal = parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal;
|
|
38
|
+
const timer = setTimeout(() => deadline.abort(), waitSeconds * 1000);
|
|
39
|
+
const bounded = new AgentClient(new Transport(client.transport.origin, {
|
|
40
|
+
fetchImpl: client.transport.fetch, timeoutMs: client.transport.timeoutMs, signal,
|
|
41
|
+
}), client.store);
|
|
42
|
+
let page = null, polls = 0, cursor = requested.cursor;
|
|
43
|
+
const timedOut = () => ({ reason: 'timeout', polls, page, nextCursor: cursor });
|
|
44
|
+
try {
|
|
45
|
+
if (parent?.aborted) fail('cancelled', 'The wait was cancelled.', { nextCursor: cursor });
|
|
46
|
+
while (!signal.aborted) {
|
|
47
|
+
page = await readChanges(bounded, { ...requested, cursor }); polls++;
|
|
48
|
+
cursor = page.nextCursor;
|
|
49
|
+
if (page.items.length || page.omittedIds.length || page.hasMore)
|
|
50
|
+
return { reason: 'changes', polls, page, nextCursor: cursor };
|
|
51
|
+
await sleep(Math.max(catalog.changes.pollIntervalSeconds, page.pollAfterSeconds) * 1000, undefined, { signal });
|
|
52
|
+
}
|
|
53
|
+
if (parent?.aborted) fail('cancelled', 'The wait was cancelled.', { nextCursor: cursor });
|
|
54
|
+
return timedOut();
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (parent?.aborted) fail('cancelled', 'The wait was cancelled.', { nextCursor: cursor });
|
|
57
|
+
if (deadline.signal.aborted && (error?.name === 'AbortError' || error?.code === 'network_unavailable')) return timedOut();
|
|
58
|
+
// Completed empty polls may advance the cursor. Preserve it on an error,
|
|
59
|
+
// but do not claim that a failed final request was an empty successful read.
|
|
60
|
+
if (error?.details) error.details.nextCursor = cursor;
|
|
61
|
+
throw error;
|
|
62
|
+
} finally { clearTimeout(timer); }
|
|
63
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import open, { apps } from 'open';
|
|
2
|
+
import catalog from './command-catalog.mjs';
|
|
3
|
+
import { CredentialStore } from './store.mjs';
|
|
4
|
+
import { ORIGINS, originOf, Transport } from './transport.mjs';
|
|
5
|
+
import { AgentClient } from './client.mjs';
|
|
6
|
+
import { deviceLogin, browserLogin } from './login.mjs';
|
|
7
|
+
import { CommandJournal, describeCommands, readCommandFile } from './commands.mjs';
|
|
8
|
+
import { AgentProfiles } from './profiles.mjs';
|
|
9
|
+
import { readChanges, watchChanges } from './changes.mjs';
|
|
10
|
+
import { fail, safeError, exitCode } from './errors.mjs';
|
|
11
|
+
|
|
12
|
+
export const commands = {
|
|
13
|
+
'auth login': { summary: 'Open browser sign-in and choose access. No tutorial or token to copy.', options: ['flow', 'browser', 'no-browser', 'scope', 'interactive'] },
|
|
14
|
+
'auth logout': { summary: 'Revoke this terminal session on the server, then erase local credentials.', options: [] },
|
|
15
|
+
'auth status': { summary: 'Read current server identity and permission ceiling.', options: [] },
|
|
16
|
+
whoami: { summary: 'Read current server identity.', options: [] },
|
|
17
|
+
capabilities: { summary: 'Discover collections, fields, limits and write availability.', options: [] },
|
|
18
|
+
'profiles list': { summary: 'List selectable profiles in this connection within the current grant.', options: [] },
|
|
19
|
+
'profiles create': { summary: 'Create a narrower profile from JSON with a UUID chosen once; never switches automatically.', options: ['file'] },
|
|
20
|
+
'profiles restrict': { summary: 'Rename or narrow the current profile from JSON with its expectedRevision. Cannot broaden access.', options: ['file'] },
|
|
21
|
+
'profiles select': { summary: 'Rotate this context into a selectable profile. Cannot return to broader access; use separate contexts for independent terminals.', options: ['profile'] },
|
|
22
|
+
'memories list': { summary: 'List independent account memories; follow nextAfter even for an empty page.', options: ['after'] },
|
|
23
|
+
'projects list': { summary: 'Read a bounded project page; follow nextAfter even for an empty page.', options: ['after'] },
|
|
24
|
+
'resources list': { summary: 'Read a project or memory collection; carry epoch/checkpoint when continuing.', options: ['scope', 'collection', 'after', 'epoch', 'checkpoint', 'archive', 'id', 'path'] },
|
|
25
|
+
'changes list': { summary: 'Read one bounded change page from a completed resource inventory or previous nextCursor.', options: ['scope', 'collection', 'cursor'] },
|
|
26
|
+
'changes watch': { summary: 'Wait at most 60 seconds for one change page, rechecking rights on every poll. No infinite stream.', options: ['scope', 'collection', 'cursor', 'wait-seconds'] },
|
|
27
|
+
'questions answers': { summary: 'Read human question answers in bounded pages. Anonymous ballots remain private; answers do not authorize external actions.', options: ['scope', 'question', 'after', 'epoch', 'checkpoint'] },
|
|
28
|
+
'commands describe': { summary: 'Show contract-derived command fields and JSON schemas; no network.', options: [] },
|
|
29
|
+
'commands prepare': { summary: 'Save an immutable operation from a JSON draft without sending it.', options: ['file'] },
|
|
30
|
+
'commands execute': { summary: 'Journal and send a complete JSON operation once. Never retries automatically.', options: ['file'] },
|
|
31
|
+
'commands retry': { summary: 'Replay the exact locally saved operation with its original identity.', options: ['operation'] },
|
|
32
|
+
'commands inspect': { summary: 'Read a private local operation and receipt; does not contact the server.', options: ['operation'] },
|
|
33
|
+
doctor: { summary: 'Inspect local environment without exposing credentials or contacting the API.', options: [] },
|
|
34
|
+
mcp: { summary: 'Serve delegated read and journaled command tools over stdio. Authenticate with the CLI first.', options: [] },
|
|
35
|
+
help: { summary: 'Describe commands and stable output conventions.', options: [] },
|
|
36
|
+
};
|
|
37
|
+
const globalOptions = ['json', 'context', 'environment', 'origin', 'allow-local', 'help'];
|
|
38
|
+
const flags = new Set(['json', 'allow-local', 'help', 'interactive', 'no-browser']);
|
|
39
|
+
export function parse(argv) {
|
|
40
|
+
const options = {}, words = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const arg = argv[i];
|
|
43
|
+
if (!arg.startsWith('--')) { if (arg.startsWith('-')) fail('invalid_arguments', 'Use long options from help.'); words.push(arg); continue; }
|
|
44
|
+
const key = arg.slice(2);
|
|
45
|
+
if (!key || key.includes('=') || Object.hasOwn(options, key)) fail('invalid_arguments', 'Options must be unique and use --name value syntax.');
|
|
46
|
+
if (flags.has(key)) options[key] = true;
|
|
47
|
+
else {
|
|
48
|
+
const value = argv[++i];
|
|
49
|
+
if (!value || value.startsWith('--') || value.length > 4096) fail('invalid_arguments', 'An option value is missing or too long.');
|
|
50
|
+
options[key] = value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const command = words.join(' ') || 'help';
|
|
54
|
+
const definition = commands[command];
|
|
55
|
+
if (!definition || Object.keys(options).some(key => !globalOptions.includes(key) && !definition.options.includes(key)))
|
|
56
|
+
fail('invalid_arguments', 'Unknown command or option. Run help.');
|
|
57
|
+
return { command, options };
|
|
58
|
+
}
|
|
59
|
+
export function help(command = 'help') {
|
|
60
|
+
return { formatVersion: 1, command, commands: command === 'help' ? commands : { [command]: commands[command] },
|
|
61
|
+
globalOptions: { context: 'default; use separate names for separate accounts or agent sessions', environment: 'development (beta default) | production', origin: 'explicit loopback origin with --allow-local, for local tests only', json: 'one versioned JSON result on stdout; progress/errors use stderr' },
|
|
62
|
+
loginOptions: { flow: 'browser (default, PKCE loopback) | device (SSH/headless)', browser: 'default (automatic) | chrome; --no-browser prints the link without opening it', scope: 'space-separated permission ceiling; browser defaults to read-only and requires explicit write selection', interactive: 'required when starting login without a terminal; never use to bypass human consent' },
|
|
63
|
+
exitCodes: { 0: 'success', 1: 'failure', 2: 'usage', 3: 'authentication', 4: 'permission', 5: 'conflict', 6: 'rate limit', 7: 'transient failure' },
|
|
64
|
+
examples: ['geniustasker auth login --environment development --context work --browser chrome', 'geniustasker projects list --environment development --context work --json', 'geniustasker resources list --scope project:ID --collection tasks --json'],
|
|
65
|
+
privacy: 'Do not put tokens in arguments, environment variables, tickets, logs or MCP configuration. This beta uses the development environment by default; access still requires an admitted account.' };
|
|
66
|
+
}
|
|
67
|
+
async function openConsent(url, browser) {
|
|
68
|
+
await open(url, { wait: false, ...(browser === 'chrome' ? { app: { name: apps.chrome } } : {}) });
|
|
69
|
+
}
|
|
70
|
+
export async function main(argv, { stdout = process.stdout, stderr = process.stderr, isTTY = process.stdin.isTTY, directory, fetchImpl, signal, openBrowserImpl = openConsent } = {}) {
|
|
71
|
+
try {
|
|
72
|
+
const { command, options } = parse(argv);
|
|
73
|
+
const emit = data => stdout.write(JSON.stringify({ formatVersion: 1, ok: true, data }, null, options.json ? 0 : 2) + '\n');
|
|
74
|
+
if (command === 'help' || options.help) { emit(help(command)); return 0; }
|
|
75
|
+
if (options.environment && !Object.hasOwn(ORIGINS, options.environment)) fail('invalid_arguments', 'Environment must be production or development.');
|
|
76
|
+
if (options.origin && options.environment) fail('invalid_arguments', 'Choose --environment or --origin, not both.');
|
|
77
|
+
const origin = originOf(options.origin ?? ORIGINS[options.environment ?? 'development'], options['allow-local']);
|
|
78
|
+
const store = new CredentialStore({ context: options.context, directory });
|
|
79
|
+
const client = new AgentClient(new Transport(origin, { fetchImpl, signal }), store);
|
|
80
|
+
let data;
|
|
81
|
+
if (command === 'auth login') {
|
|
82
|
+
if (!isTTY && !options.interactive) fail('invalid_arguments', 'Login needs an interactive human.', { remediation: 'Run auth login in a terminal. Automated clients must use an existing connection.' });
|
|
83
|
+
if (options.flow && !['device', 'browser'].includes(options.flow) || options.browser && !['chrome', 'default'].includes(options.browser) || options.browser && options['no-browser']) fail('invalid_arguments', 'Use --flow browser|device, with --browser default|chrome or --no-browser.');
|
|
84
|
+
const notify = event => {
|
|
85
|
+
if (options.json) stderr.write(JSON.stringify({ formatVersion: 1, ...event }) + '\n');
|
|
86
|
+
else if (event.event === 'consent_required') stderr.write(`\nGeniusTasker · Connect your agent\n\n${options['no-browser'] ? 'Open this link' : 'Opening your browser. If it does not open, use this link'}:\n\n${event.url}\n${event.userCode ? `\nDevice code: ${event.userCode}\n` : ''}\nSign in, choose access, and approve. This terminal will finish automatically.\nWaiting for your decision…\n`);
|
|
87
|
+
else if (event.event === 'browser_unavailable') stderr.write('Could not open the browser. Use the link above; login is still waiting.\n');
|
|
88
|
+
};
|
|
89
|
+
data = await (options.flow === 'device' ? deviceLogin : browserLogin)(client, { scope: options.scope ?? catalog.grants.permissions.join(' '), openBrowser: options['no-browser'] ? undefined : async url => {
|
|
90
|
+
try { await openBrowserImpl(url, options.browser); }
|
|
91
|
+
catch { notify({ event: 'browser_unavailable', message: 'Open the printed consent URL in your browser. Login is still waiting.' }); }
|
|
92
|
+
},
|
|
93
|
+
notify });
|
|
94
|
+
if (!options.json) {
|
|
95
|
+
stdout.write(`\nConnected to GeniusTasker.\nYour chosen access is ready for the CLI and MCP.\nRun geniustasker auth status${options.context ? ' --context ' + options.context : ''} to check the connection.\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
} else if (command === 'auth logout') data = await client.logout();
|
|
99
|
+
else if (command === 'profiles list') data = await new AgentProfiles(client).list();
|
|
100
|
+
else if (command === 'profiles create') data = await new AgentProfiles(client).create(await readCommandFile(options.file));
|
|
101
|
+
else if (command === 'profiles restrict') data = await new AgentProfiles(client).restrict(await readCommandFile(options.file));
|
|
102
|
+
else if (command === 'profiles select') data = await new AgentProfiles(client).select({ profileId: options.profile });
|
|
103
|
+
else if (command === 'commands describe') data = describeCommands();
|
|
104
|
+
else if (command === 'commands prepare') data = await new CommandJournal(client).prepare(await readCommandFile(options.file));
|
|
105
|
+
else if (command === 'commands execute') data = await new CommandJournal(client).execute(await readCommandFile(options.file));
|
|
106
|
+
else if (command === 'commands retry') data = await new CommandJournal(client).retry(options.operation);
|
|
107
|
+
else if (command === 'commands inspect') data = await new CommandJournal(client).inspect(options.operation);
|
|
108
|
+
else if (command === 'doctor') data = await client.doctor();
|
|
109
|
+
else if (command === 'whoami' || command === 'auth status') data = await client.get('me');
|
|
110
|
+
else if (command === 'capabilities') data = await client.get('capabilities');
|
|
111
|
+
else if (command === 'projects list' || command === 'memories list') data = await client.get(command === 'projects list' ? 'projects' : 'memories', { after: options.after });
|
|
112
|
+
else if (command === 'changes list' || command === 'changes watch') {
|
|
113
|
+
const input = { scope: options.scope, collection: options.collection, cursor: options.cursor };
|
|
114
|
+
data = command === 'changes list' ? await readChanges(client, input) :
|
|
115
|
+
await watchChanges(client, { ...input, ...(options['wait-seconds'] === undefined ? {} : { waitSeconds: Number(options['wait-seconds']) }) });
|
|
116
|
+
}
|
|
117
|
+
else if (command === 'resources list' || command === 'questions answers') {
|
|
118
|
+
if (!(command === 'questions answers' ? options.scope?.startsWith('project:') : /^(project|memory):[^\u0000-\u001f]+$/.test(options.scope ?? '')) || !(command === 'resources list' ? options.collection : options.question) || options.after && (!options.epoch || !options.checkpoint))
|
|
119
|
+
fail('invalid_arguments', 'Use --scope project:ID or memory:ID and --collection for resources or --question for answers; continuation needs --epoch and --checkpoint.');
|
|
120
|
+
data = await client.get(command === 'resources list' ? 'resources' : 'answers', Object.fromEntries(commands[command].options.filter(key => options[key] !== undefined).map(key => [key, options[key]])));
|
|
121
|
+
} else if (command === 'mcp') {
|
|
122
|
+
const { runMcp } = await import('./mcp.mjs');
|
|
123
|
+
const server = await runMcp(client);
|
|
124
|
+
const close = () => { void server.close(); };
|
|
125
|
+
signal?.addEventListener('abort', close, { once: true });
|
|
126
|
+
if (signal?.aborted) close();
|
|
127
|
+
return 0; // stdout belongs exclusively to JSON-RPC.
|
|
128
|
+
}
|
|
129
|
+
emit(data); return 0;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
const safe = safeError(error);
|
|
132
|
+
stderr.write(JSON.stringify({ formatVersion: 1, ok: false, error: safe.toJSON() }) + '\n');
|
|
133
|
+
return exitCode(safe);
|
|
134
|
+
}
|
|
135
|
+
}
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { fail } from './errors.mjs';
|
|
2
|
+
|
|
3
|
+
export function credentials(result, origin, now = Date.now()) {
|
|
4
|
+
const token = (value, kind) => typeof value === 'string' && new RegExp(`^gta1\\.[a-f0-9]{64}\\.wd${kind}_[A-Za-z0-9_-]{43}$`).test(value);
|
|
5
|
+
if (!token(result?.access_token, 'a') || !token(result?.refresh_token, 'r') || result.token_type !== 'Bearer' ||
|
|
6
|
+
!Number.isSafeInteger(result.expires_in) || result.expires_in < 1 || result.expires_in > 3600 ||
|
|
7
|
+
['connection_id', 'profile_id', 'session_id'].some(key => typeof result[key] !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(result[key])))
|
|
8
|
+
fail('invalid_response', 'The authorization response was invalid.', { remediation: 'Revoke the new session in Account → Agents and retry login.' });
|
|
9
|
+
return { formatVersion: 1, origin, clientId: 'geniustasker-cli',
|
|
10
|
+
access_token: result.access_token, refresh_token: result.refresh_token, token_type: 'Bearer', expires_in: result.expires_in,
|
|
11
|
+
connection_id: result.connection_id, profile_id: result.profile_id, session_id: result.session_id,
|
|
12
|
+
expiresAt: now + result.expires_in * 1000, refreshPending: false };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AgentClient {
|
|
16
|
+
constructor(transport, store) { this.transport = transport; this.store = store; }
|
|
17
|
+
async session() {
|
|
18
|
+
const session = await this.store.read();
|
|
19
|
+
if (!session) fail('not_authenticated', 'This context is not connected.', { remediation: 'Run auth login with this context and environment.' });
|
|
20
|
+
if (session.formatVersion !== 1 || session.origin !== this.transport.origin || session.clientId !== 'geniustasker-cli')
|
|
21
|
+
fail('credential_origin_mismatch', 'This context belongs to another API environment.', { remediation: 'Select its original environment, or use a different --context for the new environment.' });
|
|
22
|
+
// Validate private storage before ever sending any token to the network.
|
|
23
|
+
credentials(session, session.origin);
|
|
24
|
+
if (!Number.isSafeInteger(session.expiresAt)) fail('invalid_credential_store', 'The session expiry is invalid.');
|
|
25
|
+
return session;
|
|
26
|
+
}
|
|
27
|
+
async authorizedSession() {
|
|
28
|
+
return this.store.locked(async () => {
|
|
29
|
+
const session = await this.session();
|
|
30
|
+
if (session.refreshPending) fail('reauthentication_required', 'The previous renewal outcome is unknown.', { remediation: 'Run auth logout, then auth login. Do not replay the previous refresh token.' });
|
|
31
|
+
if (session.expiresAt > Date.now() + 30000) return session;
|
|
32
|
+
return this.renewLocked(session);
|
|
33
|
+
}, this.transport.signal);
|
|
34
|
+
}
|
|
35
|
+
/** Only called while holding the credential store lock. Selection rotates
|
|
36
|
+
* access and refresh together so an in-flight old command cannot acquire the
|
|
37
|
+
* new profile's identity. It never extends the server's absolute lifetime. */
|
|
38
|
+
async renewLocked(session, selection) {
|
|
39
|
+
await this.store.write({ ...session, refreshPending: true });
|
|
40
|
+
let result;
|
|
41
|
+
try {
|
|
42
|
+
result = await this.transport.oauth('token', { grant_type: 'refresh_token', refresh_token: session.refresh_token,
|
|
43
|
+
...(selection ? { profile_id: selection.profileId, profile_revision: String(selection.expectedRevision), source_revision: String(selection.expectedSourceRevision) } : {}) });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// These two server outcomes happen before token consumption/rotation.
|
|
46
|
+
// Every other refusal or lost reply retains the uncertainty marker.
|
|
47
|
+
if (selection && error?.status === 400 && ['profile_selection_conflict', 'profile_selection_denied'].includes(error.code)) {
|
|
48
|
+
await this.store.write(session);
|
|
49
|
+
error.remediation = 'List profiles again. Only a human can approve broader access; a changed profile requires a fresh selection.';
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
const next = credentials(result, this.transport.origin);
|
|
54
|
+
if (next.connection_id !== session.connection_id || next.profile_id !== (selection?.profileId ?? session.profile_id) || next.session_id !== session.session_id)
|
|
55
|
+
fail('invalid_response', 'Renewal returned an unexpected identity.');
|
|
56
|
+
await this.store.write(next);
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
async selectProfile(selection) {
|
|
60
|
+
return this.store.locked(async () => {
|
|
61
|
+
const session = await this.session();
|
|
62
|
+
if (session.refreshPending) fail('reauthentication_required', 'The previous renewal outcome is unknown.', { remediation: 'Run auth logout, then auth login; do not replay the previous refresh token.' });
|
|
63
|
+
if (selection.sourceProfileId !== session.profile_id || selection.connectionId !== session.connection_id)
|
|
64
|
+
fail('profile_selection_conflict', 'Another process changed this context.', { status: 409, remediation: 'Read profiles again. Use separate --context names for independent terminals.' });
|
|
65
|
+
const next = await this.renewLocked(session, selection);
|
|
66
|
+
return { connected: true, connectionId: next.connection_id, profileId: next.profile_id, sessionId: next.session_id };
|
|
67
|
+
}, this.transport.signal);
|
|
68
|
+
}
|
|
69
|
+
async accessToken() { return (await this.authorizedSession()).access_token; }
|
|
70
|
+
async get(resource, query) {
|
|
71
|
+
if (!['me', 'capabilities', 'projects', 'memories', 'resources', 'profiles', 'answers', 'changes'].includes(resource)) fail('invalid_arguments', 'Unknown delegated resource.');
|
|
72
|
+
const token = await this.accessToken();
|
|
73
|
+
// No blanket retries, no automatic writes. Revocation and ACL errors remain
|
|
74
|
+
// visible to the agent. A caller chooses how to recover using the envelope.
|
|
75
|
+
return this.transport.request(`/api/agents/v1/${resource}`, { token, query });
|
|
76
|
+
}
|
|
77
|
+
async logout() {
|
|
78
|
+
return this.store.locked(async () => {
|
|
79
|
+
if (!await this.store.read()) return { connected: false, alreadyDisconnected: true };
|
|
80
|
+
const session = await this.session();
|
|
81
|
+
// Keep credentials on failure so revocation can be retried. A used refresh
|
|
82
|
+
// token can revoke a family whose rotation response was lost.
|
|
83
|
+
await this.transport.oauth('revoke', { token: session.refresh_token, token_type_hint: 'refresh_token' });
|
|
84
|
+
await this.store.remove();
|
|
85
|
+
return { connected: false, revoked: true };
|
|
86
|
+
}, this.transport.signal);
|
|
87
|
+
}
|
|
88
|
+
async doctor() {
|
|
89
|
+
const session = await this.store.read();
|
|
90
|
+
return { node: process.versions.node, origin: this.transport.origin, connectedLocally: Boolean(session),
|
|
91
|
+
sameEnvironment: session ? session.origin === this.transport.origin : null,
|
|
92
|
+
needsLogin: session ? session.refreshPending === true : true, storage: 'private OS-user file',
|
|
93
|
+
publication: 'local preview; npm distribution is not enabled', writes: 'Discover server capabilities before attempting changes.' };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// Generated from contracts/agents.tsp. Do not edit by hand.
|
|
2
|
+
export default {
|
|
3
|
+
"maxCommandBytes": 32768,
|
|
4
|
+
"commands": {
|
|
5
|
+
"approval.cancel": {
|
|
6
|
+
"fields": [
|
|
7
|
+
"requestVersion"
|
|
8
|
+
],
|
|
9
|
+
"module": "Project/ProjectTaskComment",
|
|
10
|
+
"permission": "approvals:write"
|
|
11
|
+
},
|
|
12
|
+
"approval.consume": {
|
|
13
|
+
"fields": [
|
|
14
|
+
"requestVersion",
|
|
15
|
+
"actionJson"
|
|
16
|
+
],
|
|
17
|
+
"module": "Project/ProjectTaskComment",
|
|
18
|
+
"permission": "approvals:write"
|
|
19
|
+
},
|
|
20
|
+
"approval.request": {
|
|
21
|
+
"create": true,
|
|
22
|
+
"fields": [
|
|
23
|
+
"task",
|
|
24
|
+
"taskRevision",
|
|
25
|
+
"title",
|
|
26
|
+
"summary",
|
|
27
|
+
"actionJson",
|
|
28
|
+
"approver",
|
|
29
|
+
"expiresAt"
|
|
30
|
+
],
|
|
31
|
+
"module": "Project/ProjectTaskComment",
|
|
32
|
+
"permission": "approvals:write"
|
|
33
|
+
},
|
|
34
|
+
"comment.create": {
|
|
35
|
+
"create": true,
|
|
36
|
+
"fields": [
|
|
37
|
+
"task",
|
|
38
|
+
"text",
|
|
39
|
+
"mentions"
|
|
40
|
+
],
|
|
41
|
+
"module": "Project/ProjectTaskComment",
|
|
42
|
+
"permission": "comments:write"
|
|
43
|
+
},
|
|
44
|
+
"comment.delete": {
|
|
45
|
+
"delete": true,
|
|
46
|
+
"fields": [],
|
|
47
|
+
"module": "Project/ProjectTaskComment",
|
|
48
|
+
"permission": "comments:write"
|
|
49
|
+
},
|
|
50
|
+
"comment.update": {
|
|
51
|
+
"fields": [
|
|
52
|
+
"text",
|
|
53
|
+
"mentions"
|
|
54
|
+
],
|
|
55
|
+
"module": "Project/ProjectTaskComment",
|
|
56
|
+
"permission": "comments:write"
|
|
57
|
+
},
|
|
58
|
+
"goal.create": {
|
|
59
|
+
"create": true,
|
|
60
|
+
"fields": [
|
|
61
|
+
"title",
|
|
62
|
+
"owner",
|
|
63
|
+
"isTeamOwner",
|
|
64
|
+
"period",
|
|
65
|
+
"interval",
|
|
66
|
+
"startTime",
|
|
67
|
+
"taskNumber",
|
|
68
|
+
"scheduleTaskOnly",
|
|
69
|
+
"notification",
|
|
70
|
+
"alertNotification"
|
|
71
|
+
],
|
|
72
|
+
"module": "Project/ProjectMemberGoals",
|
|
73
|
+
"permission": "goals:write"
|
|
74
|
+
},
|
|
75
|
+
"goal.delete": {
|
|
76
|
+
"delete": true,
|
|
77
|
+
"fields": [],
|
|
78
|
+
"module": "Project/ProjectMemberGoals",
|
|
79
|
+
"permission": "goals:write"
|
|
80
|
+
},
|
|
81
|
+
"goal.update": {
|
|
82
|
+
"fields": [
|
|
83
|
+
"title",
|
|
84
|
+
"owner",
|
|
85
|
+
"isTeamOwner",
|
|
86
|
+
"period",
|
|
87
|
+
"interval",
|
|
88
|
+
"startTime",
|
|
89
|
+
"taskNumber",
|
|
90
|
+
"scheduleTaskOnly",
|
|
91
|
+
"notification",
|
|
92
|
+
"alertNotification"
|
|
93
|
+
],
|
|
94
|
+
"module": "Project/ProjectMemberGoals",
|
|
95
|
+
"permission": "goals:write"
|
|
96
|
+
},
|
|
97
|
+
"memory.create": {
|
|
98
|
+
"create": true,
|
|
99
|
+
"fields": [
|
|
100
|
+
"title",
|
|
101
|
+
"productKey",
|
|
102
|
+
"summary",
|
|
103
|
+
"instructions",
|
|
104
|
+
"color"
|
|
105
|
+
],
|
|
106
|
+
"module": "Memory/$",
|
|
107
|
+
"permission": "memory:write"
|
|
108
|
+
},
|
|
109
|
+
"memory.delete": {
|
|
110
|
+
"delete": true,
|
|
111
|
+
"fields": [],
|
|
112
|
+
"module": "Memory/$",
|
|
113
|
+
"permission": "memory:write"
|
|
114
|
+
},
|
|
115
|
+
"memory.page.create": {
|
|
116
|
+
"create": true,
|
|
117
|
+
"fields": [
|
|
118
|
+
"title",
|
|
119
|
+
"path",
|
|
120
|
+
"summary",
|
|
121
|
+
"blocks",
|
|
122
|
+
"tags",
|
|
123
|
+
"provenance"
|
|
124
|
+
],
|
|
125
|
+
"module": "Memory/MemoryPage",
|
|
126
|
+
"permission": "memory:write"
|
|
127
|
+
},
|
|
128
|
+
"memory.page.delete": {
|
|
129
|
+
"delete": true,
|
|
130
|
+
"fields": [],
|
|
131
|
+
"module": "Memory/MemoryPage",
|
|
132
|
+
"permission": "memory:write"
|
|
133
|
+
},
|
|
134
|
+
"memory.page.update": {
|
|
135
|
+
"fields": [
|
|
136
|
+
"title",
|
|
137
|
+
"path",
|
|
138
|
+
"summary",
|
|
139
|
+
"blocks",
|
|
140
|
+
"tags",
|
|
141
|
+
"provenance"
|
|
142
|
+
],
|
|
143
|
+
"module": "Memory/MemoryPage",
|
|
144
|
+
"permission": "memory:write"
|
|
145
|
+
},
|
|
146
|
+
"memory.update": {
|
|
147
|
+
"fields": [
|
|
148
|
+
"title",
|
|
149
|
+
"productKey",
|
|
150
|
+
"summary",
|
|
151
|
+
"instructions",
|
|
152
|
+
"color"
|
|
153
|
+
],
|
|
154
|
+
"module": "Memory/$",
|
|
155
|
+
"permission": "memory:write"
|
|
156
|
+
},
|
|
157
|
+
"question.close": {
|
|
158
|
+
"fields": [],
|
|
159
|
+
"module": "Project/ProjectVote",
|
|
160
|
+
"permission": "questions:write"
|
|
161
|
+
},
|
|
162
|
+
"question.create": {
|
|
163
|
+
"create": true,
|
|
164
|
+
"fields": [
|
|
165
|
+
"task",
|
|
166
|
+
"question",
|
|
167
|
+
"description",
|
|
168
|
+
"answerKind",
|
|
169
|
+
"options",
|
|
170
|
+
"selection",
|
|
171
|
+
"anonymous",
|
|
172
|
+
"eligibleUsers",
|
|
173
|
+
"closesAt"
|
|
174
|
+
],
|
|
175
|
+
"module": "$private/VoteDraft",
|
|
176
|
+
"permission": "questions:write"
|
|
177
|
+
},
|
|
178
|
+
"task.archive": {
|
|
179
|
+
"fields": [],
|
|
180
|
+
"module": "Project/ProjectTask",
|
|
181
|
+
"permission": "tasks:write"
|
|
182
|
+
},
|
|
183
|
+
"task.create": {
|
|
184
|
+
"create": true,
|
|
185
|
+
"fields": [
|
|
186
|
+
"title",
|
|
187
|
+
"description",
|
|
188
|
+
"type",
|
|
189
|
+
"owner",
|
|
190
|
+
"status",
|
|
191
|
+
"enableDueDate",
|
|
192
|
+
"dueDate",
|
|
193
|
+
"estimateDays",
|
|
194
|
+
"quantity",
|
|
195
|
+
"quantityUnit",
|
|
196
|
+
"autoArchive",
|
|
197
|
+
"autoArchiveDays",
|
|
198
|
+
"autoArchiveHours",
|
|
199
|
+
"dependencies",
|
|
200
|
+
"tags"
|
|
201
|
+
],
|
|
202
|
+
"module": "Project/ProjectTask",
|
|
203
|
+
"permission": "tasks:write"
|
|
204
|
+
},
|
|
205
|
+
"task.report": {
|
|
206
|
+
"fields": [
|
|
207
|
+
"phase",
|
|
208
|
+
"summary",
|
|
209
|
+
"nextSteps",
|
|
210
|
+
"evidence",
|
|
211
|
+
"mentions"
|
|
212
|
+
],
|
|
213
|
+
"module": "Project/ProjectTask",
|
|
214
|
+
"permission": "tasks:write"
|
|
215
|
+
},
|
|
216
|
+
"task.restore": {
|
|
217
|
+
"fields": [],
|
|
218
|
+
"module": "Project/ProjectTask",
|
|
219
|
+
"permission": "tasks:write"
|
|
220
|
+
},
|
|
221
|
+
"task.update": {
|
|
222
|
+
"fields": [
|
|
223
|
+
"title",
|
|
224
|
+
"description",
|
|
225
|
+
"type",
|
|
226
|
+
"owner",
|
|
227
|
+
"status",
|
|
228
|
+
"enableDueDate",
|
|
229
|
+
"dueDate",
|
|
230
|
+
"estimateDays",
|
|
231
|
+
"quantity",
|
|
232
|
+
"quantityUnit",
|
|
233
|
+
"autoArchive",
|
|
234
|
+
"autoArchiveDays",
|
|
235
|
+
"autoArchiveHours",
|
|
236
|
+
"dependencies",
|
|
237
|
+
"tags"
|
|
238
|
+
],
|
|
239
|
+
"module": "Project/ProjectTask",
|
|
240
|
+
"permission": "tasks:write"
|
|
241
|
+
},
|
|
242
|
+
"ticket.create": {
|
|
243
|
+
"create": true,
|
|
244
|
+
"fields": [
|
|
245
|
+
"title",
|
|
246
|
+
"description",
|
|
247
|
+
"kind",
|
|
248
|
+
"placement",
|
|
249
|
+
"sprint",
|
|
250
|
+
"parent",
|
|
251
|
+
"details",
|
|
252
|
+
"planning"
|
|
253
|
+
],
|
|
254
|
+
"module": "Project/ProjectTask",
|
|
255
|
+
"permission": "tasks:write"
|
|
256
|
+
},
|
|
257
|
+
"ticket.plan": {
|
|
258
|
+
"fields": [
|
|
259
|
+
"changes"
|
|
260
|
+
],
|
|
261
|
+
"module": "Project/$",
|
|
262
|
+
"permission": "tasks:write"
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
"changes": {
|
|
266
|
+
"pollIntervalSeconds": 5,
|
|
267
|
+
"maxWatchSeconds": 60
|
|
268
|
+
},
|
|
269
|
+
"grants": {
|
|
270
|
+
"permissions": [
|
|
271
|
+
"projects:read",
|
|
272
|
+
"tasks:read",
|
|
273
|
+
"tasks:write",
|
|
274
|
+
"comments:write",
|
|
275
|
+
"questions:write",
|
|
276
|
+
"approvals:write",
|
|
277
|
+
"goals:read",
|
|
278
|
+
"goals:write",
|
|
279
|
+
"memory:read",
|
|
280
|
+
"memory:write",
|
|
281
|
+
"media:read",
|
|
282
|
+
"media:write"
|
|
283
|
+
],
|
|
284
|
+
"maxResources": 200,
|
|
285
|
+
"maxProfilesPerConnection": 20
|
|
286
|
+
}
|
|
287
|
+
};
|