nofax 0.2.2 → 0.2.4

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 CHANGED
@@ -11,7 +11,7 @@ Nofax is a small open-source bridge between an agent and a human. Local mode can
11
11
 
12
12
  No Nofax account. No paid model API. No inbound port on your machine. MIT licensed.
13
13
 
14
- > **Current status:** local Nofax is `0.2.1`. The optional Cloudflare Worker is the upcoming `0.3.0` remote surface and is developed alongside the local package.
14
+ > **Current status:** local Nofax is on the stable `0.2.x` line. The optional Cloudflare Worker is the upcoming `0.3.0` remote surface and is developed alongside the local package.
15
15
 
16
16
  ## Why Nofax
17
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nofax",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "mcpName": "io.github.AKzar1el/nofax",
5
5
  "description": "Human-in-the-loop approvals and notifications for AI coding agents via CLI, MCP, and agent hooks.",
6
6
  "type": "module",
@@ -30,9 +30,51 @@ function result(value) {
30
30
  };
31
31
  }
32
32
 
33
+ const OUTPUT_REQUEST_ID = z.string().min(24).max(84).describe('Durable Nofax request handle.');
34
+
35
+ const PENDING_OUTPUT_SCHEMA = z.object({
36
+ status: z.literal('pending').describe('The request is unresolved and must not be treated as approval.'),
37
+ requestId: OUTPUT_REQUEST_ID,
38
+ mustWait: z.literal(true).describe('Signals that the caller must keep waiting for a terminal human response.'),
39
+ instruction: z.string().min(1).describe('Fail-closed next-step instruction for the caller.')
40
+ });
41
+
42
+ const WAIT_OUTPUT_SCHEMA = z.object({
43
+ status: z.enum(['pending', 'resolved']).describe('Whether the human request is still pending or has reached a terminal response.'),
44
+ requestId: OUTPUT_REQUEST_ID,
45
+ mustWait: z.boolean().optional().describe('Present and true while the request remains pending.'),
46
+ decision: z.string().min(1).max(80).optional().describe('Terminal human decision when resolved, such as allow, deny, refine, or an explicit choice value.'),
47
+ text: z.string().min(1).max(2000).optional().describe('Human free-text refinement when the terminal decision is refine.'),
48
+ instruction: z.string().min(1).describe('Safety-preserving instruction describing what the caller may do next.')
49
+ });
50
+
51
+ const PUBLIC_REQUEST_SCHEMA = z.object({
52
+ requestId: OUTPUT_REQUEST_ID,
53
+ kind: z.enum(['approval', 'choice', 'refinement']).describe('Interaction mode that created the durable request.'),
54
+ status: z.enum(['pending', 'resolved']).describe('Current durable request state.'),
55
+ createdAt: z.string().min(1).describe('ISO-8601 creation timestamp.'),
56
+ resolvedAt: z.string().min(1).optional().describe('ISO-8601 terminal-response timestamp when resolved.'),
57
+ decision: z.string().min(1).max(80).optional().describe('Terminal human decision when resolved.'),
58
+ text: z.string().min(1).max(2000).optional().describe('Human refinement text when one was supplied.')
59
+ });
60
+
61
+ const NOTIFY_OUTPUT_SCHEMA = z.object({
62
+ status: z.literal('sent').describe('The notification transport call completed successfully; this is never approval.')
63
+ });
64
+
65
+ const GET_REQUEST_OUTPUT_SCHEMA = z.object({
66
+ status: z.literal('ok').describe('The durable request was read successfully.'),
67
+ request: PUBLIC_REQUEST_SCHEMA.describe('Safe request metadata and terminal state; secret response topics are omitted.')
68
+ });
69
+
70
+ const LIST_PENDING_OUTPUT_SCHEMA = z.object({
71
+ status: z.literal('ok').describe('The pending-request scan completed successfully.'),
72
+ requests: z.array(PUBLIC_REQUEST_SCHEMA).describe('Bounded unresolved request projections with no secret response topics.')
73
+ });
74
+
33
75
  export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
34
76
  const server = new McpServer(
35
- { name: 'nofax', version: '0.2.2' },
77
+ { name: 'nofax', version: '0.2.4' },
36
78
  { instructions: SERVER_INSTRUCTIONS }
37
79
  );
38
80
 
@@ -40,11 +82,12 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
40
82
  'nofax_notify',
41
83
  {
42
84
  title: 'Send Nofax notification',
43
- description: 'Send a one-way phone notification. This tool is informational and does not create a human-response wait.',
85
+ description: 'Send a one-way phone notification. This tool is informational and does not create a human-response wait. Notification transport success never counts as approval.',
44
86
  inputSchema: z.object({
45
- title: z.string().min(1).max(120).optional(),
46
- message: z.string().min(1).max(2200)
87
+ title: z.string().min(1).max(120).optional().describe('Optional notification title shown to the human; defaults to "Nofax".'),
88
+ message: z.string().min(1).max(2200).describe('Notification body shown to the human.')
47
89
  }),
90
+ outputSchema: NOTIFY_OUTPUT_SCHEMA,
48
91
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
49
92
  },
50
93
  async (args) => result(await handlers.notify(args))
@@ -56,10 +99,11 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
56
99
  title: 'Request human approval',
57
100
  description: 'Send Allow/Deny to the phone and return a durable pending requestId. IMPORTANT: after this tool returns pending, call nofax_wait_for_response and repeat while it remains pending. Never continue the guarded action without a terminal Allow response. Set allowRefine when the human should also be able to send refinement text.',
58
101
  inputSchema: z.object({
59
- title: z.string().min(1).max(120).optional(),
60
- message: z.string().min(1).max(2200),
61
- allowRefine: z.boolean().optional()
102
+ title: z.string().min(1).max(120).optional().describe('Optional approval prompt title shown to the human; defaults to "Nofax approval".'),
103
+ message: z.string().min(1).max(2200).describe('Guarded action or decision context shown to the human.'),
104
+ allowRefine: z.boolean().optional().describe('When true, also let the human return free-text refinement instead of only Allow or Deny.')
62
105
  }),
106
+ outputSchema: PENDING_OUTPUT_SCHEMA,
63
107
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
64
108
  },
65
109
  async (args) => result(await handlers.requestApproval(args))
@@ -71,13 +115,17 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
71
115
  title: 'Request human choice',
72
116
  description: 'Send up to three explicit options to the phone and return a durable pending requestId. IMPORTANT: call nofax_wait_for_response and repeat while pending; do not choose on the human\'s behalf.',
73
117
  inputSchema: z.object({
74
- title: z.string().min(1).max(120).optional(),
75
- message: z.string().min(1).max(2200),
118
+ title: z.string().min(1).max(120).optional().describe('Optional choice prompt title shown to the human; defaults to "Nofax choice".'),
119
+ message: z.string().min(1).max(2200).describe('Question or decision context shown to the human.'),
76
120
  options: z.array(z.union([
77
- z.string().min(1).max(80),
78
- z.object({ value: z.string().min(1).max(80), label: z.string().min(1).max(32) })
79
- ])).min(1).max(3)
121
+ z.string().min(1).max(80).describe('String shorthand for a choice value; its first 32 characters are also used as the human-facing label.'),
122
+ z.object({
123
+ value: z.string().min(1).max(80).describe('Choice value returned as the terminal human decision.'),
124
+ label: z.string().min(1).max(32).describe('Short human-facing label displayed for this choice.')
125
+ })
126
+ ])).min(1).max(3).describe('One to three explicit choices to present to the human.')
80
127
  }),
128
+ outputSchema: PENDING_OUTPUT_SCHEMA,
81
129
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
82
130
  },
83
131
  async (args) => result(await handlers.requestChoice(args))
@@ -89,9 +137,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
89
137
  title: 'Request human refinement',
90
138
  description: 'Ask the human for free-text refinement through the configured Nofax Refine iOS Shortcut. Returns a durable pending requestId. IMPORTANT: call nofax_wait_for_response and repeat while pending.',
91
139
  inputSchema: z.object({
92
- title: z.string().min(1).max(120).optional(),
93
- message: z.string().min(1).max(2200)
140
+ title: z.string().min(1).max(120).optional().describe('Optional refinement prompt title shown to the human; defaults to "Nofax refinement".'),
141
+ message: z.string().min(1).max(2200).describe('Context or draft the human should refine with free text.')
94
142
  }),
143
+ outputSchema: PENDING_OUTPUT_SCHEMA,
95
144
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }
96
145
  },
97
146
  async (args) => result(await handlers.requestRefinement(args))
@@ -103,9 +152,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
103
152
  title: 'Wait for Nofax human response',
104
153
  description: 'Long-poll a durable Nofax request for up to 240 seconds. If the result is pending, you MUST call this tool again with the same requestId. Repeat indefinitely until a terminal response is returned or the user explicitly changes/cancels the goal. Do not continue the guarded action while pending.',
105
154
  inputSchema: z.object({
106
- requestId: z.string().min(24).max(84),
107
- waitSeconds: z.number().int().min(1).max(240).optional()
155
+ requestId: z.string().min(24).max(84).describe('Durable request handle returned by a nofax_request_* tool.'),
156
+ waitSeconds: z.number().int().min(1).max(240).optional().describe('Maximum seconds to long-poll during this call; defaults to 240. A timeout still returns pending, never approval.')
108
157
  }),
158
+ outputSchema: WAIT_OUTPUT_SCHEMA,
109
159
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }
110
160
  },
111
161
  async (args) => result(await handlers.waitForResponse(args))
@@ -116,7 +166,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
116
166
  {
117
167
  title: 'Get Nofax request',
118
168
  description: 'Recover safe metadata and terminal state for a durable Nofax request. Secret response topics are never returned.',
119
- inputSchema: z.object({ requestId: z.string().min(24).max(84) }),
169
+ inputSchema: z.object({
170
+ requestId: z.string().min(24).max(84).describe('Durable Nofax request handle to inspect without exposing its secret response topic.')
171
+ }),
172
+ outputSchema: GET_REQUEST_OUTPUT_SCHEMA,
120
173
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
121
174
  },
122
175
  async (args) => result(await handlers.getRequest(args))
@@ -127,7 +180,10 @@ export function buildMcpServer({ handlers = createMcpToolHandlers() } = {}) {
127
180
  {
128
181
  title: 'List pending Nofax requests',
129
182
  description: 'List a bounded set of unresolved Nofax request handles for recovery after client or conversation interruption. Secret response topics are never returned.',
130
- inputSchema: z.object({ limit: z.number().int().min(1).max(100).optional() }),
183
+ inputSchema: z.object({
184
+ limit: z.number().int().min(1).max(100).optional().describe('Maximum number of unresolved requests to return; defaults to 20.')
185
+ }),
186
+ outputSchema: LIST_PENDING_OUTPUT_SCHEMA,
131
187
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
132
188
  },
133
189
  async (args) => result(await handlers.listPending(args))
package/src/requests.mjs CHANGED
@@ -103,11 +103,10 @@ export async function savePendingRequest({ home, env, request }) {
103
103
 
104
104
  export async function loadRequest({ home, env, requestId }) {
105
105
  const { file, terminal } = paths({ home, env, requestId });
106
+ const claimed = await loadTerminalClaim(terminal);
107
+ if (claimed !== null) return claimed;
106
108
  try {
107
- const current = validateRequest(JSON.parse(await readFile(file, 'utf8')));
108
- if (current.status === 'resolved') return current;
109
- const claimed = await loadTerminalClaim(terminal);
110
- return claimed ?? current;
109
+ return validateRequest(JSON.parse(await readFile(file, 'utf8')));
111
110
  } catch (error) {
112
111
  if (error?.code === 'ENOENT') throw new Error('NOFAX_REQUEST_NOT_FOUND');
113
112
  if (error instanceof SyntaxError) throw new Error('NOFAX_REQUEST_INVALID_JSON');
@@ -126,11 +125,10 @@ export async function resolveRequest({ home, env, requestId, response, resolvedA
126
125
  decision: response.decision,
127
126
  ...(response.text === undefined ? {} : { text: response.text })
128
127
  });
129
- const { file, terminal } = paths({ home, env, requestId });
128
+ const { terminal } = paths({ home, env, requestId });
130
129
  if (!await claimTerminal(terminal, resolved)) {
131
130
  return loadRequest({ home, env, requestId });
132
131
  }
133
- await atomicWrite(file, resolved);
134
132
  return resolved;
135
133
  }
136
134