quoxcli 0.3.1 → 0.4.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/lib/api/endpoints/actions.js +15 -0
- package/lib/api/endpoints/admin.js +20 -0
- package/lib/api/endpoints/blog.js +16 -0
- package/lib/api/endpoints/chat.js +7 -0
- package/lib/api/endpoints/contactsq.js +4 -0
- package/lib/api/endpoints/devServerRegistry.js +25 -0
- package/lib/api/endpoints/engagement.js +26 -0
- package/lib/api/endpoints/evidence.js +20 -0
- package/lib/api/endpoints/hat.js +14 -0
- package/lib/api/endpoints/index.js +12 -0
- package/lib/api/endpoints/loops.js +26 -0
- package/lib/api/endpoints/mon.js +173 -0
- package/lib/api/endpoints/objectives.js +6 -0
- package/lib/api/endpoints/quoxseo.js +6 -0
- package/lib/api/endpoints/server.js +21 -0
- package/lib/api/endpoints/setup.js +6 -0
- package/lib/api/endpoints/v1.js +34 -0
- package/lib/commands/actions.js +81 -11
- package/lib/commands/agent.js +11 -4
- package/lib/commands/agentAttach.js +46 -7
- package/lib/commands/approvals.js +5 -0
- package/lib/commands/ask.js +3 -1
- package/lib/commands/blog.js +110 -0
- package/lib/commands/chat.js +127 -28
- package/lib/commands/chatCompanion.js +27 -0
- package/lib/commands/chatState.js +45 -0
- package/lib/commands/claude.js +3 -32
- package/lib/commands/contacts.js +85 -4
- package/lib/commands/contactsSendAt.js +21 -0
- package/lib/commands/engagement.js +90 -0
- package/lib/commands/evidence.js +154 -0
- package/lib/commands/ext.js +23 -0
- package/lib/commands/fleet.js +25 -0
- package/lib/commands/hat.js +76 -0
- package/lib/commands/image.js +68 -0
- package/lib/commands/kuma.js +1050 -0
- package/lib/commands/kumaSla.js +174 -0
- package/lib/commands/licence.js +39 -1
- package/lib/commands/loop.js +291 -0
- package/lib/commands/objective.js +48 -1
- package/lib/commands/org.js +21 -4
- package/lib/commands/provider.js +58 -0
- package/lib/commands/quoxseo.js +31 -0
- package/lib/commands/research.js +79 -0
- package/lib/commands/rows.js +9 -0
- package/lib/commands/run.js +18 -1
- package/lib/commands/scope.js +2 -2
- package/lib/commands/server.js +113 -0
- package/lib/commands/shortcut.js +41 -0
- package/lib/commands/skills.js +20 -4
- package/lib/commands/vm.js +2 -2
- package/lib/commands/youtube.js +172 -0
- package/lib/core/actionItems.js +115 -0
- package/lib/core/agentAlias.js +2 -0
- package/lib/core/agentFamilies.js +7 -0
- package/lib/core/client.js +10 -2
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +1 -1
- package/lib/core/findingReconcile.js +90 -0
- package/lib/core/global-flags.js +1 -1
- package/lib/core/governedSession.js +1 -3
- package/lib/core/liveSessionsReport.js +1 -1
- package/lib/core/loopGlyph.js +97 -0
- package/lib/core/offload.js +36 -7
- package/lib/core/output.js +7 -7
- package/lib/core/sessionHeartbeat.js +12 -8
- package/lib/core/shortcut.js +83 -0
- package/lib/core/version.js +1 -1
- package/lib/core/vm.js +5 -0
- package/lib/dragon/companionView.js +34 -0
- package/lib/dragon/frames.js +118 -0
- package/lib/dragon/jobs.js +123 -0
- package/lib/dragon/renderWorker.js +15 -0
- package/lib/dragon/select.js +72 -0
- package/lib/evidence/bundleExport.js +47 -0
- package/lib/evidence/bundleVerify.js +77 -0
- package/lib/quoxon/EXTENSIONS.md +23 -0
- package/lib/quoxon/api.js +192 -6
- package/lib/quoxon/app.js +655 -89
- package/lib/quoxon/approvalFacts.js +8 -6
- package/lib/quoxon/approvalSentence.js +44 -0
- package/lib/quoxon/approvalsView.js +63 -4
- package/lib/quoxon/commandShort.js +15 -3
- package/lib/quoxon/commands.js +75 -10
- package/lib/quoxon/countdownLine.js +10 -1
- package/lib/quoxon/deck.js +30 -0
- package/lib/quoxon/demo.autoplay.js +1 -1
- package/lib/quoxon/demo.js +3 -1
- package/lib/quoxon/dividerAocl.js +4 -3
- package/lib/quoxon/dividerRule.js +3 -3
- package/lib/quoxon/dragonCompanion.js +255 -0
- package/lib/quoxon/dragonModes.js +1 -0
- package/lib/quoxon/dragonMoments.js +82 -0
- package/lib/quoxon/dragonReads.js +68 -0
- package/lib/quoxon/dragonView.js +2 -2
- package/lib/quoxon/events.js +24 -3
- package/lib/quoxon/extensions.js +276 -0
- package/lib/quoxon/inboxView.js +79 -0
- package/lib/quoxon/installCli.js +79 -0
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/loopCard.js +456 -0
- package/lib/quoxon/loopCards.js +180 -0
- package/lib/quoxon/loopStream.js +87 -0
- package/lib/quoxon/main.js +12 -3
- package/lib/quoxon/mouse.js +12 -1
- package/lib/quoxon/objectiveOffer.js +82 -0
- package/lib/quoxon/objects/index.js +22 -4
- package/lib/quoxon/objects/model.js +33 -0
- package/lib/quoxon/objects/proof.js +12 -4
- package/lib/quoxon/onboarding/shortcutOffer.js +28 -0
- package/lib/quoxon/onboarding/view.js +1 -1
- package/lib/quoxon/orgSwitch.js +27 -1
- package/lib/quoxon/planSteps.js +51 -0
- package/lib/quoxon/print.js +6 -3
- package/lib/quoxon/qdragonFly.js +7 -6
- package/lib/quoxon/qmotion.js +17 -0
- package/lib/quoxon/render.js +130 -33
- package/lib/quoxon/roster.js +26 -12
- package/lib/quoxon/rows.js +244 -1
- package/lib/quoxon/serverChip.js +42 -0
- package/lib/quoxon/session.js +10 -0
- package/lib/quoxon/slash/cliCommand.js +42 -1
- package/lib/quoxon/slash/cliLeaves.json +76 -1
- package/lib/quoxon/slash/evidenceCmd.js +34 -0
- package/lib/quoxon/slash/palette.js +1 -1
- package/lib/quoxon/slash/qpeCmd.js +35 -0
- package/lib/quoxon/slash/restart.js +25 -1
- package/lib/quoxon/slash/resume.js +18 -1
- package/lib/quoxon/slash/todoCmd.js +122 -0
- package/lib/quoxon/suggestCommand.js +33 -2
- package/lib/quoxon/toolCards.js +30 -0
- package/lib/quoxon/whatsnew.jsonl +45 -0
- package/lib/quoxon/windowOrg.js +11 -1
- package/lib/server/devServers.js +137 -0
- package/package.json +2 -1
- package/vendor/dragonlab/SOURCE +4 -0
- package/vendor/dragonlab/design/cipher/v1/cipher-master.svg +46 -0
- package/vendor/dragonlab/design/cipher/v2/character.mjs +49 -0
- package/vendor/dragonlab/design/cipher/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/cipher/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/drago/v1/character.mjs +19 -0
- package/vendor/dragonlab/design/drago/v1/drago-master.svg +56 -0
- package/vendor/dragonlab/design/drago/v2/character.mjs +87 -0
- package/vendor/dragonlab/design/drago/v4/character.mjs +67 -0
- package/vendor/dragonlab/design/drago/v4/profile.mjs +35 -0
- package/vendor/dragonlab/design/drago/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/drago/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/hyperion/v1/hyperion-master.svg +56 -0
- package/vendor/dragonlab/design/hyperion/v2/character.mjs +79 -0
- package/vendor/dragonlab/design/hyperion/v2/review.mjs +33 -0
- package/vendor/dragonlab/design/hyperion/v2/watch.mjs +56 -0
- package/vendor/dragonlab/design/lucid/recovery/arrival.mjs +22 -0
- package/vendor/dragonlab/design/lucid/recovery/character.mjs +106 -0
- package/vendor/dragonlab/design/lucid/recovery/layout.mjs +50 -0
- package/vendor/dragonlab/design/lucid/recovery/lucid-master.svg +78 -0
- package/vendor/dragonlab/design/lucid/recovery/motion.mjs +42 -0
- package/vendor/dragonlab/design/lucid/recovery/render.mjs +121 -0
- package/vendor/dragonlab/design/lucid/recovery/review.mjs +68 -0
- package/vendor/dragonlab/design/lucid/recovery/verify.mjs +84 -0
- package/vendor/dragonlab/design/lucid/recovery/watch.mjs +56 -0
- package/vendor/dragonlab/design/seer/v1/seer-master.svg +53 -0
- package/vendor/dragonlab/design/seer/v2/character.mjs +88 -0
- package/vendor/dragonlab/design/seer/v3/character.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/character.mjs +66 -0
- package/vendor/dragonlab/design/seer/v4/review.mjs +33 -0
- package/vendor/dragonlab/design/seer/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/stage3/companion-review.mjs +28 -0
- package/vendor/dragonlab/design/stage3/sentinel-art.js +39 -0
- package/vendor/dragonlab/design/stage3/sentinel-braille.js +216 -0
- package/vendor/dragonlab/design/stage3/sentinel-character.js +257 -0
- package/vendor/dragonlab/design/stage3/sentinel-icon.js +103 -0
- package/vendor/dragonlab/design/stage3/sentinel-warden-eyes.js +95 -0
- package/vendor/dragonlab/design/stage3/svg-coverage.js +148 -0
- package/vendor/dragonlab/factory/svg-rig.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +88 -0
- package/vendor/dragonlab/integration/companions-v1/cipher.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/companion.mjs +63 -0
- package/vendor/dragonlab/integration/companions-v1/drago.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/hyperion.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/lucid.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/seer.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/sentinel.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +28 -0
- package/vendor/dragonlab/integration/hyperion-v3/activity.mjs +76 -0
- package/vendor/dragonlab/lib/power-effects.js +294 -0
- package/vendor/dragonlab/lib/power-registry.js +74 -0
- package/vendor/dragonlab/vendor/quox-dragon/qart.js +348 -0
- package/vendor/dragonlab/vendor/quox-dragon/qeyes.js +213 -0
package/lib/commands/chat.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
import chalk from 'chalk';
|
|
6
6
|
import { formatDetail } from '../core/output.js';
|
|
7
7
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { lastConversation, saveLastConversation } from './chatState.js';
|
|
10
|
+
import { loadChatCompanion } from './chatCompanion.js';
|
|
11
|
+
import { companionPlan, motionPolicy, VENDORED_DRAGONLAB } from '../dragon/select.js';
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Build the POST body for /chat/stream (quox#782). The prompt and context
|
|
@@ -74,7 +78,7 @@ function generateQueryId() {
|
|
|
74
78
|
* Consume a chat SSE stream, printing chunks as they arrive.
|
|
75
79
|
* Returns { text, sessionId, agentUsed } once the stream completes.
|
|
76
80
|
*/
|
|
77
|
-
export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel, trace = false } = {}) {
|
|
81
|
+
export async function consumeStream(client, { message, agent, sessionId, context }, { onAgentLabel, trace = false, companionPath, companionDisplay } = {}) {
|
|
78
82
|
const queryId = generateQueryId();
|
|
79
83
|
const body = buildStreamQuery({ queryId, message, agent, sessionId, context });
|
|
80
84
|
|
|
@@ -90,19 +94,25 @@ export async function consumeStream(client, { message, agent, sessionId, context
|
|
|
90
94
|
onAgentLabel?.(agentUsed);
|
|
91
95
|
};
|
|
92
96
|
|
|
93
|
-
const
|
|
97
|
+
const companion = await loadChatCompanion(companionPath, { display: companionDisplay });
|
|
98
|
+
const write = text => companion ? companion.write(text) : process.stdout.write(text);
|
|
99
|
+
// Native terminal status follows legacy `done`; observe it when opted in.
|
|
100
|
+
let iter;
|
|
101
|
+
try {
|
|
102
|
+
iter = client.stream(resolveEndpoint('chat.stream').path, { service: resolveEndpoint('chat.stream').service, body, untilClose: Boolean(trace || companion) })[Symbol.asyncIterator]();
|
|
94
103
|
while (true) {
|
|
95
104
|
let step;
|
|
96
105
|
try {
|
|
97
106
|
step = await iter.next();
|
|
98
107
|
} catch (err) {
|
|
99
108
|
// A refusal before the stream opened carries the layers it really evaluated.
|
|
100
|
-
if (trace && Array.isArray(err?.body?.aocl_layers)) for (const l of err.body.aocl_layers) printTrace({ type: 'aocl.layer', ...l });
|
|
109
|
+
if (trace && Array.isArray(err?.body?.aocl_layers)) { companion?.suspend(); for (const l of err.body.aocl_layers) printTrace({ type: 'aocl.layer', ...l }); }
|
|
101
110
|
throw err;
|
|
102
111
|
}
|
|
103
112
|
if (step.done) break;
|
|
104
113
|
const event = step.value;
|
|
105
|
-
|
|
114
|
+
companion?.event(event);
|
|
115
|
+
if (trace) { companion?.suspend(); printTrace(event); }
|
|
106
116
|
switch (event.type) {
|
|
107
117
|
case 'connected':
|
|
108
118
|
break;
|
|
@@ -110,9 +120,9 @@ export async function consumeStream(client, { message, agent, sessionId, context
|
|
|
110
120
|
case 'thinking': {
|
|
111
121
|
const stage = event.stage || '';
|
|
112
122
|
if (stage === 'tool_call' || stage.includes('tool')) {
|
|
113
|
-
|
|
123
|
+
write(chalk.dim(` ⚙ using tool: ${event.message}`) + '\n');
|
|
114
124
|
} else if (process.argv.includes('--verbose') || process.argv.includes('-v')) {
|
|
115
|
-
|
|
125
|
+
write(chalk.dim(` … ${event.message}`) + '\n');
|
|
116
126
|
}
|
|
117
127
|
break;
|
|
118
128
|
}
|
|
@@ -120,19 +130,19 @@ export async function consumeStream(client, { message, agent, sessionId, context
|
|
|
120
130
|
case 'chunk':
|
|
121
131
|
printLabel();
|
|
122
132
|
sawChunk = true;
|
|
123
|
-
|
|
133
|
+
write(event.content || '');
|
|
124
134
|
text += event.content || '';
|
|
125
135
|
break;
|
|
126
136
|
|
|
127
137
|
case 'done':
|
|
128
138
|
if (!sawChunk && event.full_response) {
|
|
129
139
|
printLabel();
|
|
130
|
-
|
|
140
|
+
write(event.full_response);
|
|
131
141
|
text = event.full_response;
|
|
132
142
|
}
|
|
133
143
|
if (event.session_id) resultSessionId = event.session_id;
|
|
134
144
|
if (event.agent_used) agentUsed = event.agent_used;
|
|
135
|
-
|
|
145
|
+
write('\n');
|
|
136
146
|
break;
|
|
137
147
|
|
|
138
148
|
case 'error':
|
|
@@ -142,28 +152,99 @@ export async function consumeStream(client, { message, agent, sessionId, context
|
|
|
142
152
|
break;
|
|
143
153
|
}
|
|
144
154
|
}
|
|
155
|
+
} catch (err) {
|
|
156
|
+
companion?.event({ type: 'error' });
|
|
157
|
+
throw err;
|
|
158
|
+
} finally {
|
|
159
|
+
await companion?.close();
|
|
160
|
+
try { await iter?.return?.(); } catch { /* cleanup must not mask a stream error */ }
|
|
161
|
+
}
|
|
145
162
|
|
|
146
163
|
return { text, sessionId: resultSessionId, agentUsed };
|
|
147
164
|
}
|
|
148
165
|
|
|
149
|
-
/**
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (sessionId) body.session_id = sessionId;
|
|
166
|
+
/** The words for a failed turn: the server's plain message first, the bare error code only as a fallback. */
|
|
167
|
+
export function turnFailureText(result, httpStatus) {
|
|
168
|
+
return result?.message || result?.error || `chat turn failed (HTTP ${httpStatus})`;
|
|
169
|
+
}
|
|
154
170
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
171
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Poll a server-side chat turn until it is done. The turn keeps running on the server whatever this
|
|
175
|
+
* client does, so giving up early would hide a result (and any objects it created) from the user.
|
|
176
|
+
*/
|
|
177
|
+
async function awaitTurn(client, turnId) {
|
|
178
|
+
const capMs = Number(process.env.QUOX_CHAT_TURN_TIMEOUT_MS) || 20 * 60 * 1000;
|
|
179
|
+
const fixed = Number(process.env.QUOX_CHAT_POLL_MS) || 0;
|
|
180
|
+
const started = Date.now();
|
|
181
|
+
let wait = fixed || 1500;
|
|
182
|
+
let failures = 0;
|
|
183
|
+
const ep = resolveEndpoint('chat.turn.get', { id: turnId });
|
|
184
|
+
while (true) {
|
|
185
|
+
let t;
|
|
186
|
+
try {
|
|
187
|
+
t = await client.get(ep.path, { service: ep.service });
|
|
188
|
+
failures = 0;
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err?.status === 404 || /not.found/i.test(err?.message || '')) {
|
|
191
|
+
throw new Error(`The server no longer has chat turn ${turnId} (expired, or the collector restarted). Check \`quox conversation list\` to see whether it finished.`);
|
|
192
|
+
}
|
|
193
|
+
if (++failures >= 5) throw err;
|
|
194
|
+
t = { status: 'running' };
|
|
195
|
+
}
|
|
196
|
+
if (t.status === 'done') {
|
|
197
|
+
if (t.http_status >= 400) throw new Error(turnFailureText(t.result, t.http_status));
|
|
198
|
+
return t.result || {};
|
|
199
|
+
}
|
|
200
|
+
if (t.status === 'error') throw new Error(`chat turn ${turnId} failed on the server: ${t.error || 'unknown error'}`);
|
|
201
|
+
if (Date.now() - started > capMs) {
|
|
202
|
+
throw new Error(`Chat turn ${turnId} is still running after ${Math.round(capMs / 1000)}s. It has not been lost: fetch it with \`quox chat --resume ${turnId}\`.`);
|
|
203
|
+
}
|
|
204
|
+
await sleep(wait);
|
|
205
|
+
if (!fixed) wait = Math.min(wait + 500, 5000);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** One-shot (non-streaming) chat via POST /chat/claude, kept server side as a pollable turn. */
|
|
210
|
+
async function sendOneShot(client, { message, agent, sessionId, resumeTurn }) {
|
|
211
|
+
let data;
|
|
212
|
+
if (resumeTurn) {
|
|
213
|
+
data = await awaitTurn(client, resumeTurn);
|
|
214
|
+
} else {
|
|
215
|
+
const body = { prompt: message };
|
|
216
|
+
if (agent) body.selected_agent = agent;
|
|
217
|
+
if (sessionId) body.session_id = sessionId;
|
|
218
|
+
|
|
219
|
+
// Prefer: respond-async returns 202 {turn_id} at once, so no client timeout can orphan a
|
|
220
|
+
// multi-tool turn. An older collector ignores it and answers synchronously (handled below).
|
|
221
|
+
data = await client.post(resolveEndpoint('chat.claude').path, {
|
|
222
|
+
service: resolveEndpoint('chat.claude').service,
|
|
223
|
+
body,
|
|
224
|
+
headers: { Prefer: 'respond-async' },
|
|
225
|
+
timeout: 30000,
|
|
226
|
+
});
|
|
227
|
+
if (data && data.turn_id && data.status === 'running') data = await awaitTurn(client, data.turn_id);
|
|
228
|
+
}
|
|
160
229
|
|
|
161
230
|
const response = data.result || data.response || data.text || JSON.stringify(data);
|
|
162
231
|
return { data, response, sessionId: data.session_id || sessionId };
|
|
163
232
|
}
|
|
164
233
|
|
|
234
|
+
/** Explicit --dragon-module wins; otherwise pick the dragon for the attached agent (null path = ordinary chat). */
|
|
235
|
+
export function chooseCompanion({ options, config, env = process.env, isTTY = Boolean(process.stdout.isTTY), exists = existsSync }) {
|
|
236
|
+
const policy = motionPolicy(env);
|
|
237
|
+
if (options.dragonModule) return { companionPath: options.dragonModule, companionDisplay: { still: policy.still, mono: policy.mono } };
|
|
238
|
+
let settings = {};
|
|
239
|
+
try { settings = config?.getActiveProfile?.()?.quoxon || {}; } catch { /* no profile settings */ }
|
|
240
|
+
const e = options.dragon ? { ...env, QUOX_DRAGON_COMPANION: options.dragon } : env;
|
|
241
|
+
const plan = companionPlan({ agent: options.agent, settings, env: e, isTTY, exists, bundled: VENDORED_DRAGONLAB });
|
|
242
|
+
if (!plan.enabled) return { companionPath: undefined, companionDisplay: undefined };
|
|
243
|
+
return { companionPath: plan.file, companionDisplay: { still: plan.still, mono: plan.mono, persist: plan.persist } };
|
|
244
|
+
}
|
|
245
|
+
|
|
165
246
|
export function registerChatCommands(program, ctx) {
|
|
166
|
-
const { client, auth } = ctx;
|
|
247
|
+
const { client, auth, config } = ctx;
|
|
167
248
|
|
|
168
249
|
const chat = program
|
|
169
250
|
.command('chat')
|
|
@@ -172,13 +253,25 @@ export function registerChatCommands(program, ctx) {
|
|
|
172
253
|
.option('--agent <name>', 'route to a specific agent (e.g. sentinel, cipher, nova)')
|
|
173
254
|
.option('--no-stream', 'disable streaming output (one-shot request/response)')
|
|
174
255
|
.option('--conversation <id>', 'continue a conversation (session id)')
|
|
256
|
+
.option('--continue', 'continue the last conversation for this profile and directory')
|
|
257
|
+
.option('--resume <turn>', 'fetch a chat turn that was still running (its id is printed if a turn outlives the wait)')
|
|
175
258
|
.option('--trace', 'print the turn\'s protocol events (per-layer AOCL, traces, summary) to stderr as they arrive')
|
|
259
|
+
.option('--dragon <mode>', 'dragon companion for dragon-backed agents: on, greeting or off (default from /dragon companion; art is bundled; QUOX_DRAGONLAB overrides it)')
|
|
260
|
+
.option('--dragon-module <path>', 'opt in to a trusted local dragon companion module (streaming, single-message mode)')
|
|
176
261
|
.action(async (message, options) => {
|
|
177
262
|
auth.requireAuth();
|
|
178
263
|
const streaming = options.stream !== false;
|
|
264
|
+
const profileName = (() => { try { return config?.getActiveProfileName?.(); } catch { return undefined; } })();
|
|
265
|
+
const noteConversation = (id) => {
|
|
266
|
+
if (!id) return;
|
|
267
|
+
saveLastConversation(profileName, process.cwd(), id);
|
|
268
|
+
if (program.opts().output !== 'json') process.stderr.write(`conversation: ${id}\n`);
|
|
269
|
+
};
|
|
270
|
+
if (!options.conversation && options.continue) options.conversation = lastConversation(profileName, process.cwd());
|
|
271
|
+
if (options.dragonModule && (!message || !streaming)) throw new Error('--dragon-module requires a single message with streaming enabled.');
|
|
179
272
|
|
|
180
|
-
// Interactive mode if no message
|
|
181
|
-
if (!message) {
|
|
273
|
+
// Interactive mode if no message (a --resume fetch is not interactive)
|
|
274
|
+
if (!message && !options.resume) {
|
|
182
275
|
const { default: inquirer } = await import('inquirer');
|
|
183
276
|
let currentAgent = options.agent || 'quox';
|
|
184
277
|
let sessionId = options.conversation;
|
|
@@ -216,7 +309,7 @@ export function registerChatCommands(program, ctx) {
|
|
|
216
309
|
onAgentLabel: (agentUsed) => process.stdout.write(chalk.green(`${(agentUsed || currentAgent).toUpperCase()}: `)),
|
|
217
310
|
trace: Boolean(options.trace),
|
|
218
311
|
});
|
|
219
|
-
if (result.sessionId) sessionId = result.sessionId;
|
|
312
|
+
if (result.sessionId) { sessionId = result.sessionId; saveLastConversation(profileName, process.cwd(), sessionId); }
|
|
220
313
|
console.log();
|
|
221
314
|
} else {
|
|
222
315
|
const { response, sessionId: newSessionId } = await sendOneShot(client, {
|
|
@@ -224,7 +317,7 @@ export function registerChatCommands(program, ctx) {
|
|
|
224
317
|
agent: currentAgent,
|
|
225
318
|
sessionId,
|
|
226
319
|
});
|
|
227
|
-
if (newSessionId) sessionId = newSessionId;
|
|
320
|
+
if (newSessionId) { sessionId = newSessionId; saveLastConversation(profileName, process.cwd(), sessionId); }
|
|
228
321
|
console.log(chalk.green(`${currentAgent.toUpperCase()}:`), response);
|
|
229
322
|
console.log();
|
|
230
323
|
}
|
|
@@ -236,16 +329,19 @@ export function registerChatCommands(program, ctx) {
|
|
|
236
329
|
}
|
|
237
330
|
|
|
238
331
|
// Single message mode
|
|
239
|
-
if (streaming) {
|
|
332
|
+
if (streaming && !options.resume) {
|
|
333
|
+
const companion = chooseCompanion({ options, config });
|
|
240
334
|
try {
|
|
241
|
-
await consumeStream(client, {
|
|
335
|
+
const result = await consumeStream(client, {
|
|
242
336
|
message,
|
|
243
337
|
agent: options.agent,
|
|
244
338
|
sessionId: options.conversation,
|
|
245
339
|
}, {
|
|
246
340
|
onAgentLabel: () => {}, // no label prefix in single-shot streaming; just print tokens
|
|
247
341
|
trace: Boolean(options.trace),
|
|
342
|
+
...companion,
|
|
248
343
|
});
|
|
344
|
+
noteConversation(result?.sessionId);
|
|
249
345
|
} catch (err) {
|
|
250
346
|
console.error(chalk.red(`Error: ${err.message}`));
|
|
251
347
|
process.exitCode = 1;
|
|
@@ -257,15 +353,17 @@ export function registerChatCommands(program, ctx) {
|
|
|
257
353
|
const spinner = ora('Thinking...').start();
|
|
258
354
|
|
|
259
355
|
try {
|
|
260
|
-
const { data, response } = await sendOneShot(client, {
|
|
356
|
+
const { data, response, sessionId: convId } = await sendOneShot(client, {
|
|
261
357
|
message,
|
|
262
358
|
agent: options.agent,
|
|
263
359
|
sessionId: options.conversation,
|
|
360
|
+
resumeTurn: options.resume,
|
|
264
361
|
});
|
|
265
362
|
spinner.stop();
|
|
363
|
+
noteConversation(convId);
|
|
266
364
|
|
|
267
365
|
if (program.opts().output === 'json') {
|
|
268
|
-
console.log(JSON.stringify(data, null, 2));
|
|
366
|
+
console.log(JSON.stringify({ ...data, conversation_id: convId || null }, null, 2));
|
|
269
367
|
} else {
|
|
270
368
|
console.log(response);
|
|
271
369
|
|
|
@@ -293,3 +391,4 @@ export function registerChatCommands(program, ctx) {
|
|
|
293
391
|
});
|
|
294
392
|
});
|
|
295
393
|
}
|
|
394
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
|
|
4
|
+
/** Explicit local opt-in only. A display failure must not interrupt a chat. */
|
|
5
|
+
export async function loadChatCompanion(path, { output = process.stdout, warn = console.error, display = {} } = {}) {
|
|
6
|
+
if (!path || !output.isTTY) return null;
|
|
7
|
+
try {
|
|
8
|
+
const mod = await import(pathToFileURL(resolve(path)).href);
|
|
9
|
+
const c = await mod.createCompanion({ output, ...display });
|
|
10
|
+
if (!c || !['event', 'write', 'close', 'suspend'].every(k => typeof c[k] === 'function')) {
|
|
11
|
+
try { c?.close?.(); } catch {}
|
|
12
|
+
throw new Error('Expected createCompanion() with event/write/close/suspend');
|
|
13
|
+
}
|
|
14
|
+
let active = true;
|
|
15
|
+
const stop = async () => { active = false; try { await c.close(); } catch {} };
|
|
16
|
+
return {
|
|
17
|
+
event(e) { if (active) try { c.event(e); } catch { stop(); } },
|
|
18
|
+
write(text) { if (!active) output.write(text); else c.write(text); },
|
|
19
|
+
// Trace writes to stderr: relinquish the block first to prevent overlap.
|
|
20
|
+
suspend() { if (active) try { c.suspend(); } catch { stop(); } },
|
|
21
|
+
close: stop,
|
|
22
|
+
};
|
|
23
|
+
} catch (e) {
|
|
24
|
+
warn('Dragon companion unavailable; continuing normal chat: ' + e.message);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last conversation per (profile, cwd), so `quox chat --continue` keeps context without the user
|
|
3
|
+
* copying a session id. Best-effort: a read/write failure never breaks a chat turn.
|
|
4
|
+
*/
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, readlinkSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
const stateFile = () => process.env.QUOX_CHAT_STATE || join(homedir(), '.config', 'quox', 'chat-last.json');
|
|
10
|
+
/**
|
|
11
|
+
* Who is calling: $QUOX_SESSION when set, else this terminal's tty, else nothing (a headless
|
|
12
|
+
* caller with no session id falls back to profile + cwd only). Parallel terminals/sessions must
|
|
13
|
+
* never share a "last conversation".
|
|
14
|
+
*/
|
|
15
|
+
export function callerScope(env = process.env) {
|
|
16
|
+
if (env.QUOX_SESSION) return `session:${String(env.QUOX_SESSION).slice(0, 64)}`;
|
|
17
|
+
for (const fd of [0, 1, 2]) {
|
|
18
|
+
try {
|
|
19
|
+
const t = readlinkSync(`/proc/self/fd/${fd}`);
|
|
20
|
+
if (/^\/dev\/(pts\/\d+|tty\w*)$/.test(t)) return `tty:${t}`;
|
|
21
|
+
} catch { /* not linux, or fd closed */ }
|
|
22
|
+
}
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
const keyOf = (profile, cwd) => `${profile || 'default'}|${cwd || process.cwd()}|${callerScope()}`;
|
|
26
|
+
|
|
27
|
+
function read() {
|
|
28
|
+
try { return JSON.parse(readFileSync(stateFile(), 'utf8')) || {}; } catch { return {}; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function lastConversation(profile, cwd) {
|
|
32
|
+
const v = read()[keyOf(profile, cwd)];
|
|
33
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function saveLastConversation(profile, cwd, id) {
|
|
37
|
+
if (!id) return;
|
|
38
|
+
try {
|
|
39
|
+
const all = read();
|
|
40
|
+
all[keyOf(profile, cwd)] = id;
|
|
41
|
+
const f = stateFile();
|
|
42
|
+
mkdirSync(dirname(f), { recursive: true });
|
|
43
|
+
writeFileSync(f, JSON.stringify(all, null, 2));
|
|
44
|
+
} catch { /* best-effort */ }
|
|
45
|
+
}
|
package/lib/commands/claude.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* quox claude / codex /
|
|
2
|
+
* quox claude / codex / run / sessions: governed tmux sessions. The runner
|
|
3
3
|
* lives in core/governedSession.js; this file holds argument parsing and the
|
|
4
4
|
* commander wiring. argv arrays only; user input never reaches a shell string.
|
|
5
5
|
*/
|
|
@@ -102,22 +102,6 @@ export const runClaude = ({ as, slot, ...opts } = {}, over) => gs.runGoverned({
|
|
|
102
102
|
program: 'claude',
|
|
103
103
|
...(as || slot ? { preattach: { agent: as, slot, session: opts.name } } : {}),
|
|
104
104
|
}, over);
|
|
105
|
-
/**
|
|
106
|
-
* quox gemini <args>: verbatim to `gemini`. Gemini resumes by index or
|
|
107
|
-
* "latest" (`--resume latest`, `-r 5`), not by name, so the tmux session is
|
|
108
|
-
* auto-named; reattach with `quox sessions attach NAME`.
|
|
109
|
-
*/
|
|
110
|
-
export function parseGeminiArgs(raw) {
|
|
111
|
-
let noTmux = false;
|
|
112
|
-
const args = [];
|
|
113
|
-
for (const a of raw) {
|
|
114
|
-
if (a === '--no-tmux') noTmux = true;
|
|
115
|
-
else args.push(a);
|
|
116
|
-
}
|
|
117
|
-
const resume = args.some((a) => a === '--resume' || a === '-r' || a.startsWith('--resume='));
|
|
118
|
-
return { args, name: undefined, resume, noTmux };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
105
|
/**
|
|
122
106
|
* quox wrap <program> [args...]: wrap ANY command. Known programs use their
|
|
123
107
|
* registry entry (so `quox wrap codex` == `quox codex`); unknown ones get the
|
|
@@ -135,12 +119,11 @@ export function parseRunArgs(raw) {
|
|
|
135
119
|
}
|
|
136
120
|
|
|
137
121
|
export function runRun({ bin, args = [], noTmux = false } = {}, over) {
|
|
138
|
-
const known = ['claude', 'codex'
|
|
122
|
+
const known = ['claude', 'codex'].find((k) => PROGRAMS[k].bin === bin);
|
|
139
123
|
if (known) return gs.runGoverned({ program: known, args, noTmux }, over);
|
|
140
124
|
return gs.runGoverned({ program: 'run', bin, args, noTmux }, over);
|
|
141
125
|
}
|
|
142
126
|
|
|
143
|
-
export const runGemini = (opts, over) => gs.runGoverned({ ...opts, program: 'gemini' }, over);
|
|
144
127
|
export const runCodex = (opts, over) => gs.runGoverned({ ...opts, program: 'codex' }, over);
|
|
145
128
|
export const runTui = (opts, over) => gs.runGoverned({ ...opts, program: 'tui' }, over);
|
|
146
129
|
export const runHelp = gs.runHelp;
|
|
@@ -219,18 +202,6 @@ Claude's own --agent is untouched and passes through to claude.`)
|
|
|
219
202
|
const parsed = parseCodexArgs(i >= 0 ? raw.slice(i + 1) : []);
|
|
220
203
|
process.exitCode = runCodex(parsed, over);
|
|
221
204
|
});
|
|
222
|
-
markForwardsRawArgs(program
|
|
223
|
-
.command('gemini'), 'forwards argv after the verb verbatim')
|
|
224
|
-
.description('Run Gemini CLI (same arguments as gemini) in a tmux session that survives sleep and drops')
|
|
225
|
-
.argument('[args...]', 'passed to gemini exactly as given (quox adds only --no-tmux)')
|
|
226
|
-
.helpOption(false)
|
|
227
|
-
.allowUnknownOption()
|
|
228
|
-
.allowExcessArguments()
|
|
229
|
-
.action((_a, _o, cmd) => {
|
|
230
|
-
const raw = (cmd.parent && cmd.parent.rawArgs) || process.argv;
|
|
231
|
-
const i = raw.indexOf('gemini', 2);
|
|
232
|
-
process.exitCode = runGemini(parseGeminiArgs(i >= 0 ? raw.slice(i + 1) : []), over);
|
|
233
|
-
});
|
|
234
205
|
markForwardsRawArgs(program
|
|
235
206
|
.command('wrap'), 'forwards argv after the verb verbatim')
|
|
236
207
|
.description('Run any program in a governed tmux session: quox wrap <program> [args...]')
|
|
@@ -299,7 +270,7 @@ Claude's own --agent is untouched and passes through to claude.`)
|
|
|
299
270
|
sessions.command('heartbeat <tmuxSession>', { hidden: true })
|
|
300
271
|
.description('Heartbeat one wrapped tmux session to the org session index (started by quox claude)')
|
|
301
272
|
.option('--loop', 'keep beating until the tmux session ends')
|
|
302
|
-
.option('--backend <program>', 'claude, codex,
|
|
273
|
+
.option('--backend <program>', 'claude, codex, ...')
|
|
303
274
|
.option('--agent <agent>', 'agent the session started attached to (--as)')
|
|
304
275
|
.option('--slot <slot>', 'slot the session started holding (--slot)')
|
|
305
276
|
.action(async (name, o) => {
|
package/lib/commands/contacts.js
CHANGED
|
@@ -14,6 +14,7 @@ import { line as glyphLine, printEvent } from '../core/quoxglyph.js';
|
|
|
14
14
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
15
15
|
import { runSelftest } from './contactsSelftest.js';
|
|
16
16
|
import { resolveOrgId } from './helpers.js';
|
|
17
|
+
import { atToSendAt } from './contactsSendAt.js';
|
|
17
18
|
|
|
18
19
|
const KINDS = ['person', 'organisation'];
|
|
19
20
|
const REASONS = ['dnc', 'do_not_market', 'bounced'];
|
|
@@ -38,6 +39,8 @@ async function call(fn, { forbidden } = {}) {
|
|
|
38
39
|
return await fn();
|
|
39
40
|
} catch (e) {
|
|
40
41
|
if (e instanceof QuoxApiError) {
|
|
42
|
+
// A refusal that says why (scheduled/immediate send) is shown as said, never swapped for a generic line.
|
|
43
|
+
if ((e.status === 403 || e.status === 409) && e.body && typeof e.body.reason === 'string' && e.body.reason) throw new QuoxError(e.body.reason, 1);
|
|
41
44
|
if (e.status === 403 && forbidden) throw new QuoxError(forbidden, 1);
|
|
42
45
|
if (e.status === 404 && e.body && e.body.error === 'tombstone_not_found') {
|
|
43
46
|
throw new QuoxError('No erasure tombstone exists for that identifier in this org.', 1);
|
|
@@ -109,12 +112,15 @@ export function registerContactsCommands(program, ctx) {
|
|
|
109
112
|
if (options.status) query.status = options.status;
|
|
110
113
|
if (options.relationship) query.relationship = options.relationship;
|
|
111
114
|
if (options.limit !== undefined) query.limit = Number(options.limit);
|
|
115
|
+
if (options.offset !== undefined) query.offset = Number(options.offset);
|
|
112
116
|
const ep = resolveEndpoint('contactsq.contacts.list');
|
|
113
117
|
const data = await call(() => client.get(ep.path, { service: ep.service, query }));
|
|
114
118
|
if (isJson(program)) return printJson(data);
|
|
115
119
|
const rows = (data.contacts || []).map(cardRow);
|
|
116
120
|
formatOutput({ columns: CARD_COLS, headers: CARD_HEADS, rows }, { format: program.opts().output });
|
|
117
|
-
|
|
121
|
+
const total = Number.isInteger(data.total) ? data.total : rows.length;
|
|
122
|
+
const next = (data.offset || 0) + rows.length;
|
|
123
|
+
announce('contacts list', next < total ? `${rows.length} of ${total} contacts, next: --offset ${next}` : `${rows.length} of ${total} contacts`);
|
|
118
124
|
};
|
|
119
125
|
|
|
120
126
|
addNameOptions(contacts
|
|
@@ -186,9 +192,10 @@ export function registerContactsCommands(program, ctx) {
|
|
|
186
192
|
.command('list')
|
|
187
193
|
.description('List contacts')
|
|
188
194
|
.option('--q <text>', 'name filter')
|
|
189
|
-
.option('--status <s>', 'active | candidate | archived')
|
|
195
|
+
.option('--status <s>', 'active | candidate | archived | all (default: everyone except archived)')
|
|
190
196
|
.option('--relationship <r>', 'primary relationship, e.g. customer | supplier | partner')
|
|
191
|
-
.option('--limit <n>', 'max
|
|
197
|
+
.option('--limit <n>', 'page size (default 25, max 100)')
|
|
198
|
+
.option('--offset <n>', 'skip this many (paging: the footer prints the next --offset)')
|
|
192
199
|
.action((options) => list(options));
|
|
193
200
|
|
|
194
201
|
contacts
|
|
@@ -480,7 +487,7 @@ export function registerContactsCommands(program, ctx) {
|
|
|
480
487
|
.description('Dial a contact by id through the phone bridge; you never see or type the number')
|
|
481
488
|
.argument('<id>', 'contact id (find it with `quox contacts search`)')
|
|
482
489
|
.option('--reason <text>', 'what the call is about (the agent opens with it)')
|
|
483
|
-
.option('--purpose <p>', 'service | transactional | marketing | research', 'service')
|
|
490
|
+
.option('--purpose <p>', 'service | transactional | marketing | research | personal', 'service')
|
|
484
491
|
.action(async (id, options) => {
|
|
485
492
|
auth.requireAuth();
|
|
486
493
|
const body = { contact_id: id, projection: 'dial', channel: 'voice', purpose: options.purpose };
|
|
@@ -495,6 +502,80 @@ export function registerContactsCommands(program, ctx) {
|
|
|
495
502
|
announce('contacts call', `${l.id || '?'} queued for the phone bridge`);
|
|
496
503
|
});
|
|
497
504
|
|
|
505
|
+
// ---- send: SMS or email now or at a time (the server delivers; you approve by running it) ----
|
|
506
|
+
const DEFAULT_TZ = 'Europe/London';
|
|
507
|
+
async function resolveContactArg(arg) {
|
|
508
|
+
if (/^ct_[a-z0-9_]+$/i.test(arg)) return arg;
|
|
509
|
+
const ep = resolveEndpoint('contactsq.search');
|
|
510
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: { q: arg } }));
|
|
511
|
+
const c = data.candidates || [];
|
|
512
|
+
if (!c.length) throw new QuoxError(`No contact matches "${arg}". Try \`quox contacts search\`.`, 1);
|
|
513
|
+
if (data.ambiguous || c.length > 1) {
|
|
514
|
+
throw new QuoxError(`"${arg}" matches more than one contact (${c.slice(0, 5).map((x) => `${x.display_name} ${x.id}`).join('; ')}). Pass the contact id.`, 1);
|
|
515
|
+
}
|
|
516
|
+
return c[0].id;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
contacts
|
|
520
|
+
.command('send')
|
|
521
|
+
.description('Send an SMS or email to a contact now, or at a time with --at (the server delivers it; the answer says exactly when)')
|
|
522
|
+
.argument('<contact>', 'contact id (ct_...) or an unambiguous name')
|
|
523
|
+
.argument('<text>', 'the message')
|
|
524
|
+
.option('--channel <c>', 'sms | email', 'sms')
|
|
525
|
+
.option('--at <time>', 'send later: ISO time with offset, or a clock time like 10am / 14:30 (next occurrence, in --tz)')
|
|
526
|
+
.option('--tz <zone>', 'IANA zone for --at', DEFAULT_TZ)
|
|
527
|
+
.option('--title <t>', 'subject (email)')
|
|
528
|
+
.option('--purpose <p>', 'service | transactional | marketing | research | personal', 'service')
|
|
529
|
+
.option('--reason <text>', 'why (recorded as a hash only)')
|
|
530
|
+
.action(async (contactArg, text, options) => {
|
|
531
|
+
auth.requireAuth();
|
|
532
|
+
checkEnum(options.channel, ['sms', 'email'], '--channel');
|
|
533
|
+
checkEnum(options.purpose, ['service', 'transactional', 'marketing', 'research', 'personal'], '--purpose');
|
|
534
|
+
const contactId = await resolveContactArg(contactArg);
|
|
535
|
+
const body = { contact_id: contactId, channel: options.channel, message: text, purpose: options.purpose };
|
|
536
|
+
if (options.title) body.title = options.title;
|
|
537
|
+
if (options.reason) body.reason = options.reason;
|
|
538
|
+
if (options.at) { body.send_at = atToSendAt(options.at, options.tz); body.timezone = options.tz; }
|
|
539
|
+
const ep = resolveEndpoint('contactsq.sends.create');
|
|
540
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body, retries: 0 }), {
|
|
541
|
+
forbidden: 'Blocked by contact policy, or you are not a human user. Run `quox contacts best-channel <id>` to see why.',
|
|
542
|
+
});
|
|
543
|
+
if (isJson(program)) return printJson(data);
|
|
544
|
+
if (data.job) {
|
|
545
|
+
formatDetail({ job: data.job.id, status: data.job.status, channel: data.job.channel, when: data.job.when, send_at: data.job.send_at });
|
|
546
|
+
return announce('contacts send', `scheduled ${data.job.id}: ${data.job.when}`);
|
|
547
|
+
}
|
|
548
|
+
if (data.status === 'sent') { formatDetail({ lease: data.lease_id, status: 'sent' }); return announce('contacts send', `${options.channel} sent`); }
|
|
549
|
+
formatDetail({ lease: data.lease_id, status: data.status, error: data.error, reason: data.reason });
|
|
550
|
+
announce('contacts send', `FAILED: ${data.reason || data.error}`);
|
|
551
|
+
process.exitCode = 1;
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const scheduled = contacts.command('scheduled').description('Scheduled sends: what is queued, and cancel one before it goes out');
|
|
555
|
+
scheduled
|
|
556
|
+
.command('list')
|
|
557
|
+
.option('--status <s>', 'comma list of pending,sending,sent,failed,cancelled (default pending,sending)')
|
|
558
|
+
.action(async (options) => {
|
|
559
|
+
auth.requireAuth();
|
|
560
|
+
const ep = resolveEndpoint('contactsq.sends.scheduled');
|
|
561
|
+
const data = await call(() => client.get(ep.path, { service: ep.service, query: options.status ? { status: options.status } : {} }), { forbidden: 'Scheduled sends are for human users.' });
|
|
562
|
+
if (isJson(program)) return printJson(data);
|
|
563
|
+
const rows = (data.jobs || []).map((j) => ({ id: j.id, contact: j.contact_id, channel: j.channel, status: j.status, when: j.when || j.finished_at || '', outcome: j.outcome_reason || j.outcome_code || '' }));
|
|
564
|
+
formatOutput({ columns: ['id', 'contact', 'channel', 'status', 'when', 'outcome'], rows }, { format: program.opts().output });
|
|
565
|
+
announce('contacts scheduled list', `${rows.length} jobs`);
|
|
566
|
+
});
|
|
567
|
+
scheduled
|
|
568
|
+
.command('cancel')
|
|
569
|
+
.argument('<id>', 'scheduled send id (ss_...)')
|
|
570
|
+
.action(async (id) => {
|
|
571
|
+
auth.requireAuth();
|
|
572
|
+
const ep = resolveEndpoint('contactsq.sends.cancel', { id });
|
|
573
|
+
const data = await call(() => client.post(ep.path, { service: ep.service, body: {} }), { forbidden: 'Cancelling a scheduled send is for human users.' });
|
|
574
|
+
if (isJson(program)) return printJson(data);
|
|
575
|
+
formatDetail({ job: data.job.id, status: data.job.status });
|
|
576
|
+
announce('contacts scheduled cancel', data.job.id);
|
|
577
|
+
});
|
|
578
|
+
|
|
498
579
|
contacts
|
|
499
580
|
.command('leases')
|
|
500
581
|
.description('Lease + use ledger for a contact (who was given access, when, why; never the values)')
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { QuoxError } from '../core/errors.js';
|
|
2
|
+
|
|
3
|
+
/** "10am", "10:30", "22:15" -> the next such wall-clock time in tz as a bare local ISO the server reads in that zone. ISO input passes through. */
|
|
4
|
+
export function atToSendAt(at, tz, nowMs = Date.now()) {
|
|
5
|
+
const raw = String(at).trim();
|
|
6
|
+
if (/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}/.test(raw) || /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) return raw;
|
|
7
|
+
const m = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i.exec(raw);
|
|
8
|
+
if (!m) throw new QuoxError(`--at must be an ISO time (2026-10-25T10:00:00+00:00) or a clock time such as 10am or 14:30, got "${raw}"`, 2);
|
|
9
|
+
let h = Number(m[1]);
|
|
10
|
+
const mi = Number(m[2] || 0);
|
|
11
|
+
if (m[3]) { if (h < 1 || h > 12) throw new QuoxError('--at hour must be 1 to 12 with am/pm', 2); h = (h % 12) + (m[3].toLowerCase() === 'pm' ? 12 : 0); }
|
|
12
|
+
if (h > 23 || mi > 59) throw new QuoxError('--at is not a valid clock time', 2);
|
|
13
|
+
const parts = (ms) => Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
|
14
|
+
const n = parts(nowMs);
|
|
15
|
+
const pad = (v) => String(v).padStart(2, '0');
|
|
16
|
+
let day = Date.UTC(Number(n.year), Number(n.month) - 1, Number(n.day));
|
|
17
|
+
if (h * 60 + mi <= Number(n.hour) * 60 + Number(n.minute)) day += 86400000; // already passed today: tomorrow
|
|
18
|
+
const d = new Date(day);
|
|
19
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(h)}:${pad(mi)}`;
|
|
20
|
+
}
|
|
21
|
+
|