nofax 0.2.3 → 0.2.5

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.3",
3
+ "version": "0.2.5",
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.3' },
77
+ { name: 'nofax', version: '0.2.5' },
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/mcp-tools.mjs CHANGED
@@ -140,6 +140,17 @@ export function createMcpToolHandlers(overrides = {}) {
140
140
  return pendingResult(remote.requestId);
141
141
  }
142
142
 
143
+ async function createDurableRemote(kind, input) {
144
+ let persisted;
145
+ const remote = await deps.createRemoteRequestImpl({
146
+ ...input,
147
+ beforePublish: async (prepared) => {
148
+ persisted = await persistRemote({ kind, remote: prepared });
149
+ }
150
+ });
151
+ return persisted ?? persistRemote({ kind, remote });
152
+ }
153
+
143
154
  return {
144
155
  async notify({ title = 'Nofax', message }) {
145
156
  const current = await config();
@@ -153,7 +164,7 @@ export function createMcpToolHandlers(overrides = {}) {
153
164
 
154
165
  async requestApproval({ title = 'Nofax approval', message, allowRefine = false }) {
155
166
  const current = await config();
156
- const remote = await deps.createRemoteRequestImpl({
167
+ return createDurableRemote('approval', {
157
168
  config: current,
158
169
  title: validateText(title, 'TITLE', 120),
159
170
  message: validateText(message, 'MESSAGE'),
@@ -163,32 +174,29 @@ export function createMcpToolHandlers(overrides = {}) {
163
174
  ],
164
175
  includeRefine: allowRefine === true
165
176
  });
166
- return persistRemote({ kind: 'approval', remote });
167
177
  },
168
178
 
169
179
  async requestChoice({ title = 'Nofax choice', message, options }) {
170
180
  const current = await config();
171
181
  const normalized = validateChoiceOptions(options);
172
- const remote = await deps.createRemoteRequestImpl({
182
+ return createDurableRemote('choice', {
173
183
  config: current,
174
184
  title: validateText(title, 'TITLE', 120),
175
185
  message: validateText(message, 'MESSAGE'),
176
186
  options: normalized,
177
187
  includeRefine: false
178
188
  });
179
- return persistRemote({ kind: 'choice', remote });
180
189
  },
181
190
 
182
191
  async requestRefinement({ title = 'Nofax refinement', message }) {
183
192
  const current = await config();
184
- const remote = await deps.createRemoteRequestImpl({
193
+ return createDurableRemote('refinement', {
185
194
  config: current,
186
195
  title: validateText(title, 'TITLE', 120),
187
196
  message: validateText(message, 'MESSAGE'),
188
197
  options: [],
189
198
  includeRefine: true
190
199
  });
191
- return persistRemote({ kind: 'refinement', remote });
192
200
  },
193
201
 
194
202
  async waitForResponse({ requestId, waitSeconds }) {
package/src/ntfy.mjs CHANGED
@@ -96,6 +96,7 @@ export async function createRemoteRequest({
96
96
  options = [],
97
97
  includeRefine = false,
98
98
  shortcutName = config.refineShortcutName ?? DEFAULT_REFINE_SHORTCUT,
99
+ beforePublish = async () => {},
99
100
  fetchImpl = fetch
100
101
  }) {
101
102
  const normalized = normalizeOptions(options);
@@ -126,8 +127,10 @@ export async function createRemoteRequest({
126
127
  }
127
128
  }
128
129
 
130
+ const remote = { requestId, responseTopic, allowed };
131
+ await beforePublish(remote);
129
132
  await sendNotification({ config, title, message, actions, fetchImpl });
130
- return { requestId, responseTopic, allowed };
133
+ return remote;
131
134
  }
132
135
 
133
136
  function parseNtfyPoll(text, requestId, allowed) {
@@ -149,7 +152,7 @@ function parseNtfyPoll(text, requestId, allowed) {
149
152
  export async function pollRemoteResponse({ config, responseTopic, requestId, allowed, fetchImpl = fetch }) {
150
153
  let response;
151
154
  try {
152
- response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=10m`, {
155
+ response = await fetchImpl(`${config.server}/${responseTopic}/json?poll=1&since=all`, {
153
156
  method: 'GET',
154
157
  headers: { accept: 'application/x-ndjson' }
155
158
  });