← Back to task

Commit 71a50df8

commit 71a50df8875dc88ba1dc0bba2bf606473db37cb5
Author: Coder Agent <coder@agents.omni>
Date:   Mon Apr 20 17:41:15 2026

    fix(agentd): IRC-style watch and stable typecheck deps
    
    Finish persistent watch formatting as a chat-style stream with
    
    user/assistant lines and /me status updates for thinking/tools.
    
    Messages are no longer truncated in watch output, so terminal
    
    wrapping handles long content naturally.
    
    Also harden local rundep handling in repl/build plumbing so
    
    typecheck.sh no longer tries to treat LocalRun paths as nixpkgs
    
    packages.
    
    Task-Id: t-819
    
    Task-Id: t-818

diff --git a/Omni/Agentd.hs b/Omni/Agentd.hs
index 8fa0502e..66e5609f 100755
--- a/Omni/Agentd.hs
+++ b/Omni/Agentd.hs
@@ -3470,19 +3470,19 @@ test =
         case formatPersistentWatchLine tz False "agent-x" line of
           Nothing -> Test.assertFailure "Expected formatted line"
           Just out ->
-            Test.assertBool "formatted user line" ("15:01:15 [agent-x] user: hello world" `Text.isInfixOf` out),
-      Test.unit "persistent watch formats tool calls" <| do
+            Test.assertBool "formatted user line" ("user> hello world" `Text.isInfixOf` out),
+      Test.unit "persistent watch formats tool calls as status lines" <| do
         let tz = Time.minutesToTimeZone 0
             line = "{\"type\":\"tool_call\",\"timestamp\":\"2026-04-11T15:01:15.946Z\",\"tool_name\":\"run_bash\",\"tool_args\":{\"command\":\"pwd && ls -la\"}}"
         case formatPersistentWatchLine tz False "agent-x" line of
           Nothing -> Test.assertFailure "Expected formatted line"
           Just out ->
-            Test.assertBool "formatted tool call line" ("tool_call run_bash cmd=pwd && ls -la" `Text.isInfixOf` out),
+            Test.assertBool "formatted tool call line" ("/me calls run_bash: pwd && ls -la" `Text.isInfixOf` out),
       Test.unit "persistent watch normalizes legacy untyped result lines" <| do
         let tz = Time.minutesToTimeZone 0
             line = "{\"success\":true,\"response\":\"done\"}"
         case formatPersistentWatchLine tz False "agent-x" line of
           Nothing -> Test.assertFailure "Expected formatted line"
           Just out ->
-            Test.assertBool "formatted legacy result line" ("result ✓: done" `Text.isInfixOf` out)
+            Test.assertBool "formatted legacy result line" ("assistant> done" `Text.isInfixOf` out)
     ]
diff --git a/Omni/Agentd/PersistentView.hs b/Omni/Agentd/PersistentView.hs
index 06b3f243..b71dab8a 100644
--- a/Omni/Agentd/PersistentView.hs
+++ b/Omni/Agentd/PersistentView.hs
@@ -36,17 +36,11 @@ formatPersistentWatchLine timezone details runId line =
    in if Text.null trimmed
         then Nothing
         else case decodeJsonObject line of
-          Nothing -> Just <| watchPrefix timezone runId Nothing <> "raw " <> clipWatchText 120 trimmed
-          Just obj ->
+          Nothing -> Just <| watchPrefix timezone runId Nothing <> "/me raw: " <> trimmed
+          Just obj -> do
+            body <- renderPersistentWatchEvent details obj
             let timestamp = topLookupTextField "timestamp" obj
-             in case topLookupTextField "type" obj of
-                  Nothing ->
-                    if details
-                      then Just <| watchPrefix timezone runId timestamp <> "event " <> clipWatchText 120 trimmed
-                      else Nothing
-                  Just _ ->
-                    let body = renderPersistentWatchEvent details obj
-                     in Just <| watchPrefix timezone runId timestamp <> body
+            pure <| watchPrefix timezone runId timestamp <> body
 
 watchPrefix :: Time.TimeZone -> Text -> Maybe Text -> Text
 watchPrefix timezone runId mTimestamp =
@@ -55,15 +49,15 @@ watchPrefix timezone runId mTimestamp =
 formatWatchTimestamp :: Time.TimeZone -> Maybe Text -> Text
 formatWatchTimestamp timezone mTimestamp =
   case mTimestamp of
-    Nothing -> "--:--:--"
+    Nothing -> "--:--"
     Just ts ->
       case parseWatchTimestamp ts of
-        Nothing -> "--:--:--"
+        Nothing -> "--:--"
         Just utc ->
           Text.pack
             <| Time.formatTime
               Time.defaultTimeLocale
-              "%H:%M:%S"
+              "%H:%M"
               (Time.utcToLocalTime timezone utc)
 
 parseWatchTimestamp :: Text -> Maybe Time.UTCTime
@@ -73,77 +67,85 @@ parseWatchTimestamp ts =
         <|> Time.parseTimeM True Time.defaultTimeLocale "%Y-%m-%dT%H:%M:%S%Q%z" input
         <|> Time.parseTimeM True Time.defaultTimeLocale "%Y-%m-%d %H:%M:%S%Q UTC" input
 
-renderPersistentWatchEvent :: Bool -> Aeson.Object -> Text
+renderPersistentWatchEvent :: Bool -> Aeson.Object -> Maybe Text
 renderPersistentWatchEvent details obj =
   case topLookupTextField "type" obj of
     Just "session" ->
-      "session cwd=" <> fromMaybe "?" (topLookupTextField "cwd" obj)
+      Just <| "/me session cwd=" <> fromMaybe "?" (topLookupTextField "cwd" obj)
     Just "model_change" ->
       let provider = fromMaybe "?" (topLookupTextField "provider" obj)
           modelId = fromMaybe "?" (topLookupTextField "modelId" obj)
-       in "model " <> provider <> "/" <> modelId
+       in Just <| "/me model " <> provider <> "/" <> modelId
     Just "thinking_level_change" ->
-      "thinking " <> fromMaybe "?" (topLookupTextField "thinkingLevel" obj)
+      Just <| "/me thinking level " <> fromMaybe "?" (topLookupTextField "thinkingLevel" obj)
     Just "message" ->
-      fromMaybe "message" (renderPersistentWatchMessage details obj)
+      renderPersistentWatchMessage details obj
     Just "infer_start" ->
-      let iter = maybe "" (\i -> " i=" <> tshow i) (topLookupIntField "iteration" obj)
-          model = maybe "" (" model=" <>) (topLookupTextField "model" obj)
-       in "infer_start" <> iter <> model
+      let suffix =
+            if details
+              then
+                Text.unwords
+                  <| catMaybes
+                    [ ("i=" <>) </ (tshow </ topLookupIntField "iteration" obj),
+                      ("model=" <>) </ topLookupTextField "model" obj
+                    ]
+              else ""
+       in Just <| "/me thinking…" <> (if Text.null suffix then "" else " " <> suffix)
     Just "tool_call" ->
-      let toolName = fromMaybe "tool" (topLookupTextField "tool_name" obj)
-          argPreview =
+      let toolName = fromMaybe "tool" (topLookupTextField "tool_name" obj <|> topLookupTextField "name" obj)
+          argText =
             case topLookupObjectField "tool_args" obj of
-              Nothing -> ""
+              Nothing -> Nothing
               Just argsObj ->
-                if details
-                  then " " <> renderJsonObjectPreview 180 argsObj
-                  else case topLookupTextField "command" argsObj of
-                    Nothing -> ""
-                    Just cmd -> " cmd=" <> clipWatchText 90 cmd
-       in "tool_call " <> toolName <> argPreview
+                topLookupTextField "command" argsObj
+                  <|> if details then Just (renderJsonObject argsObj) else Nothing
+       in Just <| "/me calls " <> toolName <> maybe "" (": " <>) argText
     Just "tool_result" ->
       let toolName = fromMaybe "tool" (topLookupTextField "tool_name" obj)
           ok = fromMaybe True (topLookupBoolField "success" obj)
           icon = if ok then "✓" else "✗"
           duration = maybe "" (\ms -> " (" <> tshow ms <> "ms)") (topLookupIntField "duration_ms" obj)
-          outputPreview =
-            case topLookupTextField "output" obj of
-              Nothing -> ""
-              Just out ->
-                let limit = if details then 220 else 120
-                 in ": " <> clipWatchText limit out
-       in "tool_result " <> icon <> " " <> toolName <> duration <> outputPreview
+          outputText = topLookupTextField "output" obj
+          base = "/me tool " <> toolName <> " " <> icon <> duration
+       in Just <| case (details, outputText) of
+            (True, Just out) -> base <> "\n" <> out
+            _ -> base
     Just "infer_end" ->
-      let iter = maybe "" (\i -> " i=" <> tshow i) (topLookupIntField "iteration" obj)
-          tokens = maybe "" (\n -> " tok=" <> tshow n) (topLookupIntField "tokens" obj)
-          duration = maybe "" (\ms -> " dur=" <> tshow ms <> "ms") (topLookupIntField "duration_ms" obj)
-          cost = maybe "" (\c -> " cost=" <> formatCost c) (topLookupNumberField "cost_cents" obj)
-          responsePreview =
+      let metricParts =
             if details
-              then case topLookupTextField "response_preview" obj of
-                Nothing -> ""
-                Just resp -> " | " <> clipWatchText 180 resp
-              else ""
-       in "infer_end" <> iter <> tokens <> duration <> cost <> responsePreview
+              then
+                catMaybes
+                  [ ("tok=" <>) </ (tshow </ topLookupIntField "tokens" obj),
+                    ("dur=" <>) </ ((<> "ms") <. tshow </ topLookupIntField "duration_ms" obj),
+                    ("cost=" <>) </ (formatCost </ topLookupNumberField "cost_cents" obj)
+                  ]
+              else []
+          preview = if details then topLookupTextField "response_preview" obj else Nothing
+          base =
+            "/me thinking done"
+              <> (if null metricParts then "" else " " <> Text.unwords metricParts)
+       in Just <| maybe base (\resp -> base <> "\n" <> resp) preview
     Just "checkpoint" ->
-      "checkpoint " <> fromMaybe "?" (topLookupTextField "name" obj)
+      Just <| "/me checkpoint " <> fromMaybe "?" (topLookupTextField "name" obj)
     Just "result" ->
       let ok = fromMaybe True (topLookupBoolField "success" obj)
-          prefix = if ok then "result ✓" else "result ✗"
           message =
             topLookupTextField "response" obj
               <|> topLookupTextField "error" obj
               <|> topLookupTextField "message" obj
-       in case message of
-            Nothing -> prefix
-            Just txt -> prefix <> ": " <> clipWatchText (if details then 220 else 120) txt
+       in if ok
+            then Just <| maybe "/me result ✓" ("assistant> " <>) message
+            else Just <| "/me result ✗" <> maybe "" (": " <>) message
     Just "custom" ->
       renderPersistentWatchCustom details obj
     Just other ->
-      "event " <> other
+      if details
+        then Just <| "/me event " <> other
+        else Nothing
     Nothing ->
-      "event"
+      if details
+        then Just "/me event"
+        else Nothing
 
 renderPersistentWatchMessage :: Bool -> Aeson.Object -> Maybe Text
 renderPersistentWatchMessage details obj = do
@@ -151,39 +153,31 @@ renderPersistentWatchMessage details obj = do
   role <- topLookupTextField "role" msgObj
   case role of
     "user" ->
-      let snippet = fromMaybe "(message)" (messageTextSnippet msgObj)
-          limit = if details then 220 else 120
-       in Just <| "user: " <> clipWatchText limit snippet
+      Just <| "user> " <> fromMaybe "(message)" (messageTextBody msgObj <|> messageThinkingBody msgObj)
     "assistant" ->
-      case messageToolCalls msgObj of
-        tool : rest ->
-          let preview =
-                if null rest
-                  then tool
-                  else tool <> " +" <> tshow (length rest)
-           in Just <| "assistant→tool " <> preview
-        [] ->
-          let snippet =
-                fromMaybe "(assistant response)"
-                  <| messageTextSnippet msgObj
-                  <|> messageThinkingSnippet msgObj
-              limit = if details then 220 else 120
-           in Just <| "assistant: " <> clipWatchText limit snippet
+      case messageTextBody msgObj of
+        Just body -> Just <| "assistant> " <> body
+        Nothing ->
+          case messageToolCalls msgObj of
+            tool : rest ->
+              Just <| "/me calls " <> Text.intercalate ", " (tool : rest)
+            [] ->
+              case messageThinkingBody msgObj of
+                Just thought -> Just <| "/me thinking: " <> thought
+                Nothing -> Just "/me assistant update"
     "toolResult" ->
       let toolName = fromMaybe "tool" (topLookupTextField "toolName" msgObj)
           isError = fromMaybe False (topLookupBoolField "isError" msgObj)
           icon = if isError then "✗" else "✓"
-          suffix =
-            case messageTextSnippet msgObj of
-              Nothing -> ""
-              Just snippet ->
-                let limit = if details then 220 else 120
-                 in ": " <> clipWatchText limit snippet
-       in Just <| "tool_result " <> icon <> " " <> toolName <> suffix
+          base = "/me tool " <> toolName <> " " <> icon
+          body = messageTextBody msgObj <|> messageThinkingBody msgObj
+       in Just <| case (details, body) of
+            (True, Just txt) -> base <> "\n" <> txt
+            _ -> base
     _ ->
-      Just <| "message " <> role
+      Just <| "/me " <> role <> " message" <> maybe "" (": " <>) (messageTextBody msgObj <|> messageThinkingBody msgObj)
 
-renderPersistentWatchCustom :: Bool -> Aeson.Object -> Text
+renderPersistentWatchCustom :: Bool -> Aeson.Object -> Maybe Text
 renderPersistentWatchCustom details obj =
   let customType = fromMaybe "custom" (topLookupTextField "custom_type" obj)
       message =
@@ -194,36 +188,56 @@ renderPersistentWatchCustom details obj =
               <|> topLookupTextField "response" dataObj
               <|> topLookupTextField "error" dataObj
               <|> topLookupTextField "content" dataObj
-      suffix =
-        case message of
-          Nothing -> ""
-          Just msg ->
-            let limit = if details then 220 else 120
-             in ": " <> clipWatchText limit msg
-   in customType <> suffix
+   in case customType of
+        "agent_complete" ->
+          Just <| maybe "/me agent complete" ("assistant> " <>) message
+        "agent_error" ->
+          Just <| "/me error" <> maybe "" (": " <>) message
+        "thinking" ->
+          Just <| "/me thinking" <> maybe "" (": " <>) message
+        "agent_start" ->
+          Just "/me agent started"
+        _ ->
+          case message of
+            Just msg -> Just <| "/me " <> customType <> ": " <> msg
+            Nothing -> if details then Just <| "/me " <> customType else Nothing
 
-messageThinkingSnippet :: Aeson.Object -> Maybe Text
-messageThinkingSnippet msgObj =
+messageTextBody :: Aeson.Object -> Maybe Text
+messageTextBody msgObj =
+  case KeyMap.lookup "content" msgObj of
+    Just (Aeson.Array parts) ->
+      let chunks =
+            [ txt
+              | Aeson.Object partObj <- Vector.toList parts,
+                topLookupTextField "type" partObj == Just "text",
+                Just txt <- [topLookupTextField "text" partObj <|> topLookupTextField "content" partObj]
+            ]
+       in joinMessageChunks chunks
+    Just (Aeson.String txt) -> Just txt
+    _ -> Nothing
+
+messageThinkingBody :: Aeson.Object -> Maybe Text
+messageThinkingBody msgObj =
   case topLookupArrayField "content" msgObj of
     Nothing -> Nothing
-    Just parts -> listToMaybe (mapMaybe extractThinking parts)
-  where
-    extractThinking (Aeson.Object partObj) = do
-      partType <- topLookupTextField "type" partObj
-      guard (partType == "thinking")
-      topLookupTextField "thinking" partObj
-    extractThinking _ = Nothing
+    Just parts ->
+      let chunks =
+            [ txt
+              | Aeson.Object partObj <- parts,
+                topLookupTextField "type" partObj == Just "thinking",
+                Just txt <- [topLookupTextField "thinking" partObj <|> topLookupTextField "text" partObj <|> topLookupTextField "content" partObj]
+            ]
+       in joinMessageChunks chunks
 
-renderJsonObjectPreview :: Int -> Aeson.Object -> Text
-renderJsonObjectPreview limit obj =
-  clipWatchText limit <| TE.decodeUtf8With TextEncodingError.lenientDecode (BL.toStrict (Aeson.encode (Aeson.Object obj)))
+joinMessageChunks :: [Text] -> Maybe Text
+joinMessageChunks chunks =
+  case filter (not <. Text.null <. Text.strip) chunks of
+    [] -> Nothing
+    keptChunks -> Just (Text.concat keptChunks)
 
-clipWatchText :: Int -> Text -> Text
-clipWatchText limit txt =
-  let cleaned = Text.strip <| Text.replace "\n" " " txt
-   in if Text.length cleaned > limit
-        then Text.take limit cleaned <> "…"
-        else cleaned
+renderJsonObject :: Aeson.Object -> Text
+renderJsonObject obj =
+  TE.decodeUtf8With TextEncodingError.lenientDecode (BL.toStrict (Aeson.encode (Aeson.Object obj)))
 
 persistentLineLabel :: Text -> Maybe Text
 persistentLineLabel line = do
diff --git a/Omni/Bild/Builder.nix b/Omni/Bild/Builder.nix
index 06a7d4aa..47096a40 100644
--- a/Omni/Bild/Builder.nix
+++ b/Omni/Bild/Builder.nix
@@ -151,8 +151,8 @@ with bild; let
           else r.contents)
         (builtins.filter (r:
           if builtins.isString r
-          then true
-          else r.tag == "ExternalRun")
+          then r != "" && !(lib.strings.hasInfix "/" r)
+          else r.tag == "ExternalRun" && r.contents != "")
         target.rundeps);
 
     rundeps_ =
diff --git a/Omni/Ide/repl.sh b/Omni/Ide/repl.sh
index 62250787..1e1ae32a 100755
--- a/Omni/Ide/repl.sh
+++ b/Omni/Ide/repl.sh
@@ -31,9 +31,18 @@ fi
   fi
   targets="${*:?}"
   json=$(bild --plan "${targets[@]}" 2>&1)
-  mapfile -t langdeps < <(jq --raw-output '.[].langdeps | select(length > 0) | join("\n")' <<< "$json")
-  mapfile -t sysdeps < <(jq --raw-output '.[].sysdeps | select(length > 0) | join("\n")' <<< "$json")
-  mapfile -t rundeps < <(jq --raw-output '.[].rundeps | select(length > 0) | join("\n")' <<< "$json")
+  mapfile -t langdeps < <(jq --raw-output '.[] | .langdeps[]? | select(type == "string" and length > 0)' <<< "$json")
+  mapfile -t sysdeps < <(jq --raw-output '.[] | .sysdeps[]? | select(type == "string" and length > 0)' <<< "$json")
+  mapfile -t rundeps < <(
+    jq --raw-output '
+      .[]
+      | .rundeps[]?
+      | if type == "string"
+        then select((contains("/") | not) and length > 0)
+        else select(.tag == "ExternalRun") | .contents | select(type == "string" and length > 0)
+        end
+    ' <<< "$json"
+  )
   exts=$(jq --raw-output '.[].namespace.ext' <<< "$json" | sort | uniq)
   packageSet=$(jq --raw-output '.[].packageSet' <<< "$json")
   module=$(jq --raw-output '.[].mainModule' <<< "$json")