@askance/cli 0.2.4 → 0.3.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 +2 -23
- package/dist/common/api-client.js +4 -0
- package/dist/hook-handler/index.js +24 -6
- package/dist/hook-handler/stop-hook.js +56 -0
- package/dist/mcp-server/index.js +11 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ npx askance login
|
|
|
15
15
|
|
|
16
16
|
## What it does
|
|
17
17
|
|
|
18
|
-
- **Hook handler** — intercepts tool calls before execution and evaluates them against your
|
|
18
|
+
- **Hook handler** — intercepts tool calls before execution and evaluates them against your cloud policy rules
|
|
19
19
|
- **MCP server** — provides tools for the agent to wait for approvals and check for instructions
|
|
20
20
|
- **CLI** — sets up config files and authenticates with the Askance cloud
|
|
21
21
|
|
|
@@ -39,33 +39,12 @@ npx askance init --all # All agents
|
|
|
39
39
|
|
|
40
40
|
## Policy rules
|
|
41
41
|
|
|
42
|
-
Policy rules in
|
|
42
|
+
Policy rules are managed in the [Askance dashboard](https://app.askance.app) and control what happens when the agent uses a tool:
|
|
43
43
|
|
|
44
44
|
- **allow** — tool call proceeds immediately
|
|
45
45
|
- **gate** — queued for approval in the dashboard
|
|
46
46
|
- **deny** — blocked automatically
|
|
47
47
|
|
|
48
|
-
```yaml
|
|
49
|
-
rules:
|
|
50
|
-
- name: "Allow read-only tools"
|
|
51
|
-
match:
|
|
52
|
-
tool: "^(Read|Glob|Grep|WebSearch)$"
|
|
53
|
-
action: allow
|
|
54
|
-
|
|
55
|
-
- name: "Gate file writes"
|
|
56
|
-
match:
|
|
57
|
-
tool: "^(Edit|Write)$"
|
|
58
|
-
action: gate
|
|
59
|
-
risk: medium
|
|
60
|
-
|
|
61
|
-
- name: "Deny destructive commands"
|
|
62
|
-
match:
|
|
63
|
-
tool: "^Bash$"
|
|
64
|
-
command: "(rm -rf|chmod 777)"
|
|
65
|
-
action: deny
|
|
66
|
-
risk: high
|
|
67
|
-
```
|
|
68
|
-
|
|
69
48
|
## Dashboard
|
|
70
49
|
|
|
71
50
|
Manage approvals at [app.askance.app](https://app.askance.app) — approve, deny, or send instructions to your agent from any device.
|
|
@@ -9,6 +9,7 @@ exports.apiGet = apiGet;
|
|
|
9
9
|
exports.apiPost = apiPost;
|
|
10
10
|
exports.intercept = intercept;
|
|
11
11
|
exports.getInstructions = getInstructions;
|
|
12
|
+
exports.sessionHeartbeat = sessionHeartbeat;
|
|
12
13
|
exports.waitForInstruction = waitForInstruction;
|
|
13
14
|
exports.readKeepAliveConfig = readKeepAliveConfig;
|
|
14
15
|
const node_https_1 = __importDefault(require("node:https"));
|
|
@@ -164,6 +165,9 @@ function intercept(payload) {
|
|
|
164
165
|
function getInstructions(projectId) {
|
|
165
166
|
return apiGet(`/api/instructions/${projectId}`);
|
|
166
167
|
}
|
|
168
|
+
function sessionHeartbeat(sessionId, lastMessage) {
|
|
169
|
+
return apiPost("/api/sessions/heartbeat", { sessionId, lastMessage });
|
|
170
|
+
}
|
|
167
171
|
function waitForInstruction(projectId, timeoutMs) {
|
|
168
172
|
return apiGet(`/api/instructions/${projectId}/wait?timeout=${timeoutMs}`, timeoutMs + 5000);
|
|
169
173
|
}
|
|
@@ -6,6 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
7
7
|
const api_client_1 = require("../common/api-client");
|
|
8
8
|
const CONTEXT_LINES = 20;
|
|
9
|
+
const QUESTION_TOOLS = new Set([
|
|
10
|
+
"askuserquestion", "askfollowupquestion", "askquestion",
|
|
11
|
+
"ask_user", "ask_followup", "ask_question",
|
|
12
|
+
]);
|
|
13
|
+
function isQuestionTool(toolName) {
|
|
14
|
+
return QUESTION_TOOLS.has(toolName.toLowerCase()) ||
|
|
15
|
+
toolName.toLowerCase().includes("ask");
|
|
16
|
+
}
|
|
9
17
|
function readStdin() {
|
|
10
18
|
return new Promise((resolve, reject) => {
|
|
11
19
|
let data = "";
|
|
@@ -63,14 +71,16 @@ async function main() {
|
|
|
63
71
|
// Extract recent Claude reasoning from transcript
|
|
64
72
|
input.context_summary = extractContext(input.transcript_path ?? "");
|
|
65
73
|
const config = (0, api_client_1.readConfig)();
|
|
74
|
+
const toolName = input.tool_name ?? "";
|
|
66
75
|
// Map to cloud API format (PascalCase)
|
|
67
76
|
const payload = {
|
|
68
|
-
ToolName:
|
|
77
|
+
ToolName: toolName,
|
|
69
78
|
ToolInput: input.tool_input ? JSON.stringify(input.tool_input) : undefined,
|
|
70
79
|
SessionId: input.session_id,
|
|
71
80
|
Cwd: input.cwd,
|
|
72
81
|
ContextSummary: input.context_summary,
|
|
73
82
|
ProjectId: config.projectId || undefined,
|
|
83
|
+
IsQuestion: isQuestionTool(toolName),
|
|
74
84
|
};
|
|
75
85
|
const result = await (0, api_client_1.intercept)(payload);
|
|
76
86
|
if (!result.ok || !result.data) {
|
|
@@ -111,15 +121,23 @@ async function main() {
|
|
|
111
121
|
process.exit(0);
|
|
112
122
|
}
|
|
113
123
|
if (response.action === "pending") {
|
|
124
|
+
const isQuestion = response.isQuestion || isQuestionTool(toolName);
|
|
125
|
+
const reason = isQuestion
|
|
126
|
+
? `PENDING QUESTION (${response.approvalId}): ${response.rule}. ` +
|
|
127
|
+
`This question has been forwarded to the operator on the Askance dashboard. ` +
|
|
128
|
+
`The operator will type an answer. Call the mcp__askance__wait tool with ` +
|
|
129
|
+
`approval_id "${response.approvalId}" to receive their response. ` +
|
|
130
|
+
`Continue working on other tasks while waiting.`
|
|
131
|
+
: `PENDING APPROVAL (${response.approvalId}): ${response.rule}. ` +
|
|
132
|
+
`This action is queued for human approval on the Askance dashboard. ` +
|
|
133
|
+
`Continue working on other tasks. When you need the result, call the ` +
|
|
134
|
+
`mcp__askance__wait tool with approval_id "${response.approvalId}" ` +
|
|
135
|
+
`to check if it has been approved. Once approved, retry the original action.`;
|
|
114
136
|
const output = JSON.stringify({
|
|
115
137
|
hookSpecificOutput: {
|
|
116
138
|
hookEventName: "PreToolUse",
|
|
117
139
|
permissionDecision: "deny",
|
|
118
|
-
permissionDecisionReason:
|
|
119
|
-
`This action is queued for human approval on the Askance dashboard. ` +
|
|
120
|
-
`Continue working on other tasks. When you need the result, call the ` +
|
|
121
|
-
`mcp__askance__wait tool with approval_id "${response.approval_id}" ` +
|
|
122
|
-
`to check if it has been approved. Once approved, retry the original action.`,
|
|
140
|
+
permissionDecisionReason: reason,
|
|
123
141
|
},
|
|
124
142
|
});
|
|
125
143
|
process.stdout.write(output);
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
3
7
|
const api_client_1 = require("../common/api-client");
|
|
4
8
|
function readStdin() {
|
|
5
9
|
return new Promise((resolve, reject) => {
|
|
@@ -10,6 +14,46 @@ function readStdin() {
|
|
|
10
14
|
process.stdin.on("error", reject);
|
|
11
15
|
});
|
|
12
16
|
}
|
|
17
|
+
function extractLastAssistantMessage(transcriptPath) {
|
|
18
|
+
try {
|
|
19
|
+
if (!transcriptPath || !node_fs_1.default.existsSync(transcriptPath))
|
|
20
|
+
return "";
|
|
21
|
+
const content = node_fs_1.default.readFileSync(transcriptPath, "utf-8");
|
|
22
|
+
const lines = content.trim().split("\n");
|
|
23
|
+
// Walk backwards to find the last assistant message
|
|
24
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
25
|
+
try {
|
|
26
|
+
const entry = JSON.parse(lines[i]);
|
|
27
|
+
if (entry.type === "assistant" && entry.message?.content) {
|
|
28
|
+
const parts = Array.isArray(entry.message.content)
|
|
29
|
+
? entry.message.content
|
|
30
|
+
: [entry.message.content];
|
|
31
|
+
const texts = [];
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
if (typeof part === "string" && part.trim()) {
|
|
34
|
+
texts.push(part.trim());
|
|
35
|
+
}
|
|
36
|
+
else if (part?.type === "text" && part.text?.trim()) {
|
|
37
|
+
texts.push(part.text.trim());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (texts.length > 0) {
|
|
41
|
+
const message = texts.join("\n");
|
|
42
|
+
// Truncate to 2000 chars for storage
|
|
43
|
+
return message.length > 2000 ? message.slice(0, 2000) + "..." : message;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// skip unparseable lines
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return "";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
13
57
|
async function main() {
|
|
14
58
|
let input;
|
|
15
59
|
try {
|
|
@@ -23,6 +67,18 @@ async function main() {
|
|
|
23
67
|
if (!config.projectId) {
|
|
24
68
|
process.exit(0);
|
|
25
69
|
}
|
|
70
|
+
// Extract and post the last assistant message to the dashboard
|
|
71
|
+
if (input.session_id && input.transcript_path) {
|
|
72
|
+
try {
|
|
73
|
+
const lastMessage = extractLastAssistantMessage(input.transcript_path);
|
|
74
|
+
if (lastMessage) {
|
|
75
|
+
await (0, api_client_1.sessionHeartbeat)(input.session_id, lastMessage);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Non-critical — don't block on failure
|
|
80
|
+
}
|
|
81
|
+
}
|
|
26
82
|
// Check for queued instructions from the dashboard
|
|
27
83
|
try {
|
|
28
84
|
const result = await (0, api_client_1.getInstructions)(config.projectId);
|
package/dist/mcp-server/index.js
CHANGED
|
@@ -57,6 +57,15 @@ server.tool("wait", "Wait for a pending Askance approval to be decided. Use this
|
|
|
57
57
|
}],
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
if (approvalStatus === "answered") {
|
|
61
|
+
const response = approval.response ?? "";
|
|
62
|
+
return {
|
|
63
|
+
content: [{
|
|
64
|
+
type: "text",
|
|
65
|
+
text: `OPERATOR RESPONSE for ${approval_id}:\n\n${response}\n\nThe operator has answered your question. Use this response to continue your work.`,
|
|
66
|
+
}],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
60
69
|
return {
|
|
61
70
|
content: [{
|
|
62
71
|
type: "text",
|
|
@@ -169,13 +178,13 @@ server.tool("check_instructions", "Poll the Askance dashboard for new operator i
|
|
|
169
178
|
isError: true,
|
|
170
179
|
};
|
|
171
180
|
}
|
|
172
|
-
// Read keep-alive config from
|
|
181
|
+
// Read keep-alive config from .askance/config.json
|
|
173
182
|
const kaConfig = (0, api_client_1.readKeepAliveConfig)();
|
|
174
183
|
if (!kaConfig.enabled) {
|
|
175
184
|
return {
|
|
176
185
|
content: [{
|
|
177
186
|
type: "text",
|
|
178
|
-
text: "Keep-alive polling is disabled
|
|
187
|
+
text: "Keep-alive polling is disabled. You may stop.",
|
|
179
188
|
}],
|
|
180
189
|
};
|
|
181
190
|
}
|